add recording, fix solpush, persistence tests and tiny improvements
This commit is contained in:
@@ -126,6 +126,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# openssl/ca-certificates: TLS cert generation + HTTPS validation.
|
# openssl/ca-certificates: TLS cert generation + HTTPS validation.
|
||||||
# wget/tar/unzip/xz-utils: required by toolchain-manager for on-demand downloads.
|
# 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
|
# 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`).
|
# toolchain-manager.ts, so build-process.ts falls back to the default `cc`).
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
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 \
|
unzip \
|
||||||
xz-utils \
|
xz-utils \
|
||||||
git \
|
git \
|
||||||
|
ffmpeg \
|
||||||
clang \
|
clang \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ func h264AvailabilityDetail() string {
|
|||||||
|
|
||||||
func resetH264Encoder() {}
|
func resetH264Encoder() {}
|
||||||
|
|
||||||
|
func RequestDesktopH264Keyframe() {}
|
||||||
|
|
||||||
func resetH264EncoderHVNC() {}
|
func resetH264EncoderHVNC() {}
|
||||||
|
|
||||||
func encodeH264FrameHVNC(_ *image.RGBA) ([]byte, error) {
|
func encodeH264FrameHVNC(_ *image.RGBA) ([]byte, error) {
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ func resetH264Encoder() {
|
|||||||
closeH264EncoderLocked()
|
closeH264EncoderLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequestDesktopH264Keyframe() {
|
||||||
|
resetH264Encoder()
|
||||||
|
}
|
||||||
|
|
||||||
func closeH264EncoderLocked() {
|
func closeH264EncoderLocked() {
|
||||||
if h264Enc != nil {
|
if h264Enc != nil {
|
||||||
_ = h264Enc.Close()
|
_ = h264Enc.Close()
|
||||||
|
|||||||
@@ -797,6 +797,11 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
capture.SetQualityAndCodec(quality, codec)
|
capture.SetQualityAndCodec(quality, codec)
|
||||||
sendCommandResultSafe(env, cmdID, true, "")
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
return nil
|
return nil
|
||||||
|
case "desktop_request_keyframe":
|
||||||
|
log.Printf("desktop: request h264 keyframe")
|
||||||
|
capture.RequestDesktopH264Keyframe()
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
return nil
|
||||||
case "desktop_set_resolution":
|
case "desktop_set_resolution":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
maxH := 0 // default = 1080p cap
|
maxH := 0 // default = 1080p cap
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import (
|
|||||||
"text/template"
|
"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 = `<?xml version="1.0" encoding="UTF-8"?>
|
const launchAgentPlist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
@@ -48,26 +59,37 @@ func plistLabel() string {
|
|||||||
return "com.overlord.agent"
|
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) {
|
func getPlistPath() (string, error) {
|
||||||
usr, err := user.Current()
|
homeDir, err := currentUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
func getTargetPath() (string, error) {
|
||||||
usr, err := user.Current()
|
if err := validateStartupName(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := currentUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
func install(exePath string) error {
|
||||||
|
|
||||||
if DefaultStartupName != "" && !strings.HasPrefix(DefaultStartupName, "com.") {
|
if err := validateStartupName(); err != nil {
|
||||||
return fmt.Errorf("startup name %q is invalid for macOS: LaunchAgent labels must start with \"com.\" (e.g. com.apple.updater)", DefaultStartupName)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
targetPath, err := getTargetPath()
|
targetPath, err := getTargetPath()
|
||||||
@@ -150,11 +172,11 @@ func replaceExecutable(exePath, targetPath string) error {
|
|||||||
|
|
||||||
if err := os.Rename(tmpPath, targetPath); err != nil {
|
if err := os.Rename(tmpPath, targetPath); err != nil {
|
||||||
if removeErr := os.Remove(targetPath); removeErr == nil {
|
if removeErr := os.Remove(targetPath); removeErr == nil {
|
||||||
if err = os.Rename(tmpPath, targetPath); err == nil {
|
err = os.Rename(tmpPath, targetPath)
|
||||||
return nil
|
}
|
||||||
}
|
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 {
|
if err := os.Chmod(targetPath, 0755); err != nil {
|
||||||
@@ -165,6 +187,10 @@ func replaceExecutable(exePath, targetPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configure(exePath string) error {
|
func configure(exePath string) error {
|
||||||
|
if err := validateStartupName(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
plistPath, err := getPlistPath()
|
plistPath, err := getPlistPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get plist path: %w", err)
|
return fmt.Errorf("failed to get plist path: %w", err)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
"<string>com.overlord.agent</string>",
|
||||||
|
"<key>ProgramArguments</key>",
|
||||||
|
"<string>" + target + "</string>",
|
||||||
|
"<key>RunAtLoad</key>",
|
||||||
|
"<key>KeepAlive</key>",
|
||||||
|
} {
|
||||||
|
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, "<string>"+exePath+"</string>") {
|
||||||
|
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, "<string>com.example.updater</string>") {
|
||||||
|
t.Fatalf("custom launch agent plist missing label:\n%s", plist)
|
||||||
|
}
|
||||||
|
if !strings.Contains(plist, "<string>"+target+"</string>") {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,17 @@ import (
|
|||||||
"text/template"
|
"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]
|
const systemdService = `[Unit]
|
||||||
Description=Overlord Agent
|
Description=Overlord Agent
|
||||||
After=network.target
|
After=network.target
|
||||||
@@ -43,27 +54,27 @@ func binaryName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getSystemdPath() (string, error) {
|
func getSystemdPath() (string, error) {
|
||||||
usr, err := user.Current()
|
homeDir, err := currentUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
func getAutostartPath() (string, error) {
|
||||||
usr, err := user.Current()
|
homeDir, err := currentUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
func getTargetPath() (string, error) {
|
||||||
usr, err := user.Current()
|
homeDir, err := currentUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
func install(exePath string) error {
|
||||||
@@ -119,11 +130,11 @@ func replaceExecutable(exePath, targetPath string) error {
|
|||||||
|
|
||||||
if err := os.Rename(tmpPath, targetPath); err != nil {
|
if err := os.Rename(tmpPath, targetPath); err != nil {
|
||||||
if removeErr := os.Remove(targetPath); removeErr == nil {
|
if removeErr := os.Remove(targetPath); removeErr == nil {
|
||||||
if err = os.Rename(tmpPath, targetPath); err == nil {
|
err = os.Rename(tmpPath, targetPath)
|
||||||
return nil
|
}
|
||||||
}
|
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 {
|
if err := os.Chmod(targetPath, 0755); err != nil {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,40 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const btn = document.getElementById("rdSettingsBtn");
|
const menus = [
|
||||||
const menu = document.getElementById("rdSettingsMenu");
|
{
|
||||||
const wrap = document.getElementById("rdSettingsWrap");
|
btn: document.getElementById("rdRecordingSettingsBtn"),
|
||||||
if (!btn || !menu || !wrap) return;
|
menu: document.getElementById("rdRecordingSettingsMenu"),
|
||||||
function setOpen(open) {
|
wrap: document.getElementById("rdRecordingSettingsWrap"),
|
||||||
menu.classList.toggle("hidden", !open);
|
},
|
||||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
{
|
||||||
|
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();
|
function closeAll(except) {
|
||||||
setOpen(menu.classList.contains("hidden"));
|
for (const item of menus) {
|
||||||
});
|
if (item !== except) setOpen(item, false);
|
||||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
}
|
||||||
document.addEventListener("click", () => setOpen(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) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") setOpen(false);
|
if (e.key === "Escape") closeAll();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
const refreshBtn = document.getElementById("refreshDisplays");
|
const refreshBtn = document.getElementById("refreshDisplays");
|
||||||
const startBtn = document.getElementById("startBtn");
|
const startBtn = document.getElementById("startBtn");
|
||||||
const stopBtn = document.getElementById("stopBtn");
|
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 fullscreenBtn = document.getElementById("fullscreenBtn");
|
||||||
const mouseCtrl = document.getElementById("mouseCtrl");
|
const mouseCtrl = document.getElementById("mouseCtrl");
|
||||||
const kbdCtrl = document.getElementById("kbdCtrl");
|
const kbdCtrl = document.getElementById("kbdCtrl");
|
||||||
@@ -60,6 +64,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
let activeClientId = clientId;
|
let activeClientId = clientId;
|
||||||
|
let serverRecording = null;
|
||||||
|
let recordingTimer = null;
|
||||||
|
let pendingRecordingDownloadId = "";
|
||||||
let renderCount = 0;
|
let renderCount = 0;
|
||||||
let renderWindowStart = performance.now();
|
let renderWindowStart = performance.now();
|
||||||
let lastFrameAt = 0;
|
let lastFrameAt = 0;
|
||||||
@@ -427,6 +434,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
const isStopping = streamState === "stopping";
|
const isStopping = streamState === "stopping";
|
||||||
const isStalled = streamState === "stalled";
|
const isStalled = streamState === "stalled";
|
||||||
const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
|
const isBlocked = streamState === "offline" || streamState === "disconnected" || streamState === "error";
|
||||||
|
const recordingActive = isRecording();
|
||||||
|
|
||||||
if (startBtn) {
|
if (startBtn) {
|
||||||
startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
|
startBtn.disabled = !wsOpen || isStarting || isStreaming || isStopping || isBlocked;
|
||||||
@@ -434,6 +442,219 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
if (stopBtn) {
|
if (stopBtn) {
|
||||||
stopBtn.disabled = !wsOpen || (!isStarting && !isStreaming && !isStopping && !isStalled);
|
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 = `<i class="fa-solid fa-stop"></i><span>${label}</span>`;
|
||||||
|
} else {
|
||||||
|
recordBtn.classList.remove("recording");
|
||||||
|
recordBtn.title = "Record on the server using Canvas transport";
|
||||||
|
recordBtn.innerHTML = '<i class="fa-solid fa-circle-dot"></i><span>Record</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function startClipboardSync() {
|
||||||
@@ -879,6 +1100,12 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (webrtcMode) {
|
||||||
|
webrtcMode.addEventListener("change", function () {
|
||||||
|
updateControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function needsWebrtcFirewallWarning(mode) {
|
function needsWebrtcFirewallWarning(mode) {
|
||||||
if (firewallWarningAcked) return false;
|
if (firewallWarningAcked) return false;
|
||||||
if (mode !== "relayed" && mode !== "p2p") return false;
|
if (mode !== "relayed" && mode !== "p2p") return false;
|
||||||
@@ -919,12 +1146,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
stopBtn.addEventListener("click", function () {
|
stopBtn.addEventListener("click", function () {
|
||||||
|
if (isRecording()) stopRecording();
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
setStreamState("stopping", "Stopping stream");
|
setStreamState("stopping", "Stopping stream");
|
||||||
sendCmd("desktop_stop", {});
|
sendCmd("desktop_stop", {});
|
||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
stopAllWebrtc();
|
stopAllWebrtc();
|
||||||
});
|
});
|
||||||
|
if (recordBtn) {
|
||||||
|
recordBtn.addEventListener("click", function () {
|
||||||
|
if (isRecording()) stopRecording();
|
||||||
|
else startRecording();
|
||||||
|
});
|
||||||
|
}
|
||||||
fullscreenBtn.addEventListener("click", function () {
|
fullscreenBtn.addEventListener("click", function () {
|
||||||
if (canvasContainer.requestFullscreen) {
|
if (canvasContainer.requestFullscreen) {
|
||||||
canvasContainer.requestFullscreen();
|
canvasContainer.requestFullscreen();
|
||||||
@@ -1429,6 +1663,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
updateLatency(Number(msg.ms) || 0);
|
updateLatency(Number(msg.ms) || 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "recording_status") {
|
||||||
|
handleRecordingStatus(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg && msg.type === "clipboard_content") {
|
if (msg && msg.type === "clipboard_content") {
|
||||||
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
||||||
lastClipboardText = msg.text;
|
lastClipboardText = msg.text;
|
||||||
@@ -1448,6 +1686,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
updateLatency(Number(msg.ms) || 0);
|
updateLatency(Number(msg.ms) || 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "recording_status") {
|
||||||
|
handleRecordingStatus(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg && msg.type === "clipboard_content") {
|
if (msg && msg.type === "clipboard_content") {
|
||||||
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
if (clipboardSyncCtrl && clipboardSyncCtrl.checked && streamState === "streaming" && msg.text) {
|
||||||
lastClipboardText = msg.text;
|
lastClipboardText = msg.text;
|
||||||
@@ -1465,6 +1707,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
pushCaptureToggles();
|
pushCaptureToggles();
|
||||||
clearOfflineTimer();
|
clearOfflineTimer();
|
||||||
setStreamState("idle", "Stopped");
|
setStreamState("idle", "Stopped");
|
||||||
|
sendCmd("desktop_record_status", {});
|
||||||
fetchClientInfo().then(() => {
|
fetchClientInfo().then(() => {
|
||||||
if (displaySelect && displaySelect.value) {
|
if (displaySelect && displaySelect.value) {
|
||||||
console.debug("rd: initial select display", displaySelect.value);
|
console.debug("rd: initial select display", displaySelect.value);
|
||||||
@@ -1480,6 +1723,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
stopAllWebrtc();
|
stopAllWebrtc();
|
||||||
|
clearRecordingTimer();
|
||||||
|
serverRecording = null;
|
||||||
|
setRecordingUi(false);
|
||||||
setStreamState("disconnected", "Disconnected");
|
setStreamState("disconnected", "Disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1606,6 +1852,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
});
|
});
|
||||||
|
|
||||||
function stopOnExit() {
|
function stopOnExit() {
|
||||||
|
if (isRecording()) stopRecording();
|
||||||
if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
|
if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
sendCmd("desktop_stop", {});
|
sendCmd("desktop_stop", {});
|
||||||
|
|||||||
@@ -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 rpcSelect = document.getElementById("rpc-url");
|
||||||
const customRpcWrapper = document.getElementById("custom-rpc-wrapper");
|
const customRpcWrapper = document.getElementById("custom-rpc-wrapper");
|
||||||
const customRpcInput = document.getElementById("custom-rpc-url");
|
const customRpcInput = document.getElementById("custom-rpc-url");
|
||||||
@@ -10,28 +23,47 @@ const outputSection = document.getElementById("output-section");
|
|||||||
const outputDiv = document.getElementById("output");
|
const outputDiv = document.getElementById("output");
|
||||||
const walletInfo = document.getElementById("wallet-info");
|
const walletInfo = document.getElementById("wallet-info");
|
||||||
const walletAddress = document.getElementById("wallet-address");
|
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() {
|
async function loadRpcEndpoints() {
|
||||||
|
rpcSelect.innerHTML = "";
|
||||||
|
let endpoints = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" });
|
const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data.endpoints)) {
|
endpoints = normalizeRpcEndpoints(data?.endpoints);
|
||||||
data.endpoints.forEach((ep) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = ep;
|
|
||||||
opt.textContent = ep;
|
|
||||||
rpcSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const customOpt = document.createElement("option");
|
if (endpoints.length === 0) {
|
||||||
customOpt.value = "__custom__";
|
endpoints = DEFAULT_RPC_ENDPOINTS;
|
||||||
customOpt.textContent = "Custom RPC endpoint...";
|
}
|
||||||
rpcSelect.appendChild(customOpt);
|
|
||||||
|
normalizeRpcEndpoints(endpoints).forEach((ep) => appendRpcOption(ep));
|
||||||
|
appendRpcOption("__custom__", "Custom RPC endpoint...");
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRpcEndpoints();
|
loadRpcEndpoints();
|
||||||
@@ -192,3 +224,4 @@ publishBtn.addEventListener("click", async () => {
|
|||||||
publishBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> Publish to Solana';
|
publishBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> Publish to Solana';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
(() => {
|
||||||
let toastContainer = null;
|
let toastContainer = null;
|
||||||
let toastId = 0;
|
let toastId = 0;
|
||||||
|
|
||||||
@@ -84,3 +85,4 @@ function closeToast(id) {
|
|||||||
|
|
||||||
window.showToast = showToast;
|
window.showToast = showToast;
|
||||||
window.closeToast = closeToast;
|
window.closeToast = closeToast;
|
||||||
|
})();
|
||||||
|
|||||||
@@ -190,7 +190,7 @@
|
|||||||
<input type="text" id="server-url" name="server-url"
|
<input type="text" id="server-url" name="server-url"
|
||||||
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg focus:outline-none focus:border-blue-500 transition-colors" />
|
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg focus:outline-none focus:border-blue-500 transition-colors" />
|
||||||
<p id="server-url-hint" class="text-xs text-slate-500 mt-1">
|
<p id="server-url-hint" class="text-xs text-slate-500 mt-1">
|
||||||
Just enter your domain or IP — no <code class="text-slate-400">wss://</code>, <code class="text-slate-400">https://</code>, or any other prefix.
|
Just enter your domain or IP:PORT — no <code class="text-slate-400">wss://</code>, <code class="text-slate-400">https://</code>, or any other prefix.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
<div class="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
|||||||
@@ -126,6 +126,6 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/assets/nav.js"></script>
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
<script defer src="/assets/logs.js"></script>
|
<script type="module" src="/assets/logs.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -30,6 +30,58 @@
|
|||||||
<button id="stopBtn" class="button ghost">
|
<button id="stopBtn" class="button ghost">
|
||||||
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="recordBtn" class="button ghost" disabled title="Record on the server using Canvas transport">
|
||||||
|
<i class="fa-solid fa-circle-dot"></i><span>Record</span>
|
||||||
|
</button>
|
||||||
|
<div class="relative" id="rdRecordingSettingsWrap">
|
||||||
|
<button
|
||||||
|
id="rdRecordingSettingsBtn"
|
||||||
|
type="button"
|
||||||
|
class="button ghost"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-sliders"></i><span>Recording Settings</span>
|
||||||
|
<i class="fa-solid fa-chevron-down text-[10px] text-slate-400 ml-1"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="rdRecordingSettingsMenu"
|
||||||
|
class="hidden absolute left-0 top-full mt-2 w-[280px] rounded-xl border border-slate-700 bg-slate-900/95 backdrop-blur-md shadow-2xl shadow-black/50 z-50"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500 mb-2">Recording</div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<label class="text-sm text-slate-300 w-16" for="recordMode">Mode</label>
|
||||||
|
<select
|
||||||
|
id="recordMode"
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||||
|
title="Recording size mode"
|
||||||
|
>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-slate-300 w-16" for="recordFps">FPS</label>
|
||||||
|
<select
|
||||||
|
id="recordFps"
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||||
|
title="Recording frame rate"
|
||||||
|
>
|
||||||
|
<option value="">Source FPS</option>
|
||||||
|
<option value="3">3 fps</option>
|
||||||
|
<option value="5">5 fps</option>
|
||||||
|
<option value="10">10 fps</option>
|
||||||
|
<option value="15">15 fps</option>
|
||||||
|
<option value="30">30 fps</option>
|
||||||
|
<option value="60">60 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="fullscreenBtn" class="button ghost">
|
<button id="fullscreenBtn" class="button ghost">
|
||||||
<i class="fa-solid fa-expand"></i><span>Fullscreen</span>
|
<i class="fa-solid fa-expand"></i><span>Fullscreen</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -235,6 +287,11 @@
|
|||||||
border-color: rgba(251, 146, 60, 0.7);
|
border-color: rgba(251, 146, 60, 0.7);
|
||||||
box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.45);
|
box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.45);
|
||||||
}
|
}
|
||||||
|
#recordBtn.recording {
|
||||||
|
background: rgba(190, 18, 60, 0.9);
|
||||||
|
border-color: rgba(244, 63, 94, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
#canvasContainer:fullscreen {
|
#canvasContainer:fullscreen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/assets/main.css" />
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
<link rel="stylesheet" href="/assets/custom.css" />
|
<link rel="stylesheet" href="/assets/custom.css" />
|
||||||
|
<style>
|
||||||
|
#rpc-url,
|
||||||
|
#rpc-url option {
|
||||||
|
color: #f1f5f9;
|
||||||
|
background-color: #0f172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<script defer src="/assets/toast.js"></script>
|
<script defer src="/assets/toast.js"></script>
|
||||||
<script defer src="/assets/ripple.js"></script>
|
<script defer src="/assets/ripple.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -37,7 +44,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-300 mb-2" for="rpc-url">RPC Endpoint</label>
|
<label class="block text-sm font-medium text-slate-300 mb-2" for="rpc-url">RPC Endpoint</label>
|
||||||
<select id="rpc-url"
|
<select id="rpc-url"
|
||||||
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg focus:outline-none focus:border-purple-500 transition-colors">
|
class="w-full px-3 py-2 bg-slate-800/60 border border-slate-700 rounded-lg text-slate-100 focus:outline-none focus:border-purple-500 transition-colors">
|
||||||
</select>
|
</select>
|
||||||
<div id="custom-rpc-wrapper" class="hidden mt-2">
|
<div id="custom-rpc-wrapper" class="hidden mt-2">
|
||||||
<input type="text" id="custom-rpc-url" placeholder="https://your-rpc-endpoint.com"
|
<input type="text" id="custom-rpc-url" placeholder="https://your-rpc-endpoint.com"
|
||||||
@@ -112,6 +119,6 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/assets/nav.js"></script>
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
<script defer src="/assets/sol-publish.js"></script>
|
<script type="module" src="/assets/sol-publish.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { handlePermissionGroupsRoutes } from "./server/routes/permission-groups-
|
|||||||
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
||||||
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
||||||
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
|
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
|
||||||
|
import { handleRemoteDesktopRecordingRoutes } from "./server/routes/rd-recording-routes";
|
||||||
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
||||||
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
||||||
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
||||||
@@ -531,6 +532,9 @@ async function startServer() {
|
|||||||
pendingCommandReplies,
|
pendingCommandReplies,
|
||||||
broadcastNotificationsCleared: notificationPluginHandlers.broadcastNotificationsCleared,
|
broadcastNotificationsCleared: notificationPluginHandlers.broadcastNotificationsCleared,
|
||||||
},
|
},
|
||||||
|
rdRecording: {
|
||||||
|
secureHeaders,
|
||||||
|
},
|
||||||
wsUpgrade: {
|
wsUpgrade: {
|
||||||
isAuthorizedAgentRequest: isAuthorizedAgent,
|
isAuthorizedAgentRequest: isAuthorizedAgent,
|
||||||
},
|
},
|
||||||
@@ -687,6 +691,7 @@ async function startServer() {
|
|||||||
(req, url) => handleWebrtcRoutes(req, url),
|
(req, url) => handleWebrtcRoutes(req, url),
|
||||||
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
||||||
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
||||||
|
(req, url) => handleRemoteDesktopRecordingRoutes(req, url, routeDeps.rdRecording),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
websocket: createWebSocketRuntime({
|
websocket: createWebSocketRuntime({
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ensureDataDir } from "../paths";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import * as sessionManager from "../sessions/sessionManager";
|
||||||
|
import { safeSendViewer } from "./ws-viewer-utils";
|
||||||
|
|
||||||
|
export type RdRecordingStatus =
|
||||||
|
| "starting"
|
||||||
|
| "recording"
|
||||||
|
| "stopping"
|
||||||
|
| "stopped"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type RdRecordingSummary = {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
status: RdRecordingStatus;
|
||||||
|
startedAt: number;
|
||||||
|
stoppedAt?: number;
|
||||||
|
requestedByUserId?: number;
|
||||||
|
requestedByUsername?: string;
|
||||||
|
sourceFps?: number;
|
||||||
|
targetFps: number;
|
||||||
|
segmentSeconds: number;
|
||||||
|
compact: boolean;
|
||||||
|
encodingMode: "copy" | "compact" | "transcode";
|
||||||
|
framesWritten: number;
|
||||||
|
framesDropped: number;
|
||||||
|
framesSkipped: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
error?: string;
|
||||||
|
files: Array<{
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
downloadUrl: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveRecording = Omit<RdRecordingSummary, "files"> & {
|
||||||
|
dir: string;
|
||||||
|
process: ChildProcessWithoutNullStreams;
|
||||||
|
inputCodec: "mjpeg" | "h264";
|
||||||
|
compact: boolean;
|
||||||
|
encodingMode: "copy" | "compact" | "transcode";
|
||||||
|
h264Started: boolean;
|
||||||
|
lastFrameAt: number;
|
||||||
|
lastDebugAt: number;
|
||||||
|
stderr: string;
|
||||||
|
writeBlocked: boolean;
|
||||||
|
finalized: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartOptions = {
|
||||||
|
clientId: string;
|
||||||
|
requestedByUserId?: number;
|
||||||
|
requestedByUsername?: string;
|
||||||
|
fps?: number;
|
||||||
|
sourceFps?: number;
|
||||||
|
inputCodec?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeRecordings = new Map<string, ActiveRecording>();
|
||||||
|
|
||||||
|
function sanitizePathPart(value: string): string {
|
||||||
|
return String(value || "client")
|
||||||
|
.replace(/[^a-z0-9._-]+/gi, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 96) || "client";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingRoot(): string {
|
||||||
|
return path.join(ensureDataDir(), "rd-recordings");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientRecordingDir(clientId: string): string {
|
||||||
|
return path.join(recordingRoot(), sanitizePathPart(clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataPath(dir: string): string {
|
||||||
|
return path.join(dir, "recording.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffmpegPath(): string {
|
||||||
|
return process.env.OVERLORD_FFMPEG_PATH?.trim() || "ffmpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function envNumber(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const raw = process.env[name]?.trim();
|
||||||
|
const value = raw ? Number(raw) : fallback;
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestedFps(raw?: number): number | undefined {
|
||||||
|
if (!raw || !Number.isFinite(raw)) return undefined;
|
||||||
|
return Math.max(1, Math.min(120, Math.floor(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetFps(raw?: number): number {
|
||||||
|
return requestedFps(raw) || envNumber("OVERLORD_RD_RECORD_FPS", 15, 1, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function h264InputFps(raw?: number): number {
|
||||||
|
const fallback = envNumber("OVERLORD_RD_RECORD_H264_FPS", 60, 1, 120);
|
||||||
|
if (!raw || !Number.isFinite(raw)) return fallback;
|
||||||
|
return Math.max(1, Math.min(120, Math.floor(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentSeconds(): number {
|
||||||
|
return envNumber("OVERLORD_RD_RECORD_SEGMENT_SECONDS", 600, 30, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoBitrate(): string {
|
||||||
|
return process.env.OVERLORD_RD_RECORD_BITRATE?.trim() || "2500k";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCrf(): number {
|
||||||
|
return envNumber("OVERLORD_RD_RECORD_COMPACT_CRF", 30, 18, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPreset(): string {
|
||||||
|
const value = process.env.OVERLORD_RD_RECORD_COMPACT_PRESET?.trim() || "veryfast";
|
||||||
|
return /^(ultrafast|superfast|veryfast|faster|fast|medium|slow|slower|veryslow)$/i.test(value)
|
||||||
|
? value.toLowerCase()
|
||||||
|
: "veryfast";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingInputCodec(raw?: string): "mjpeg" | "h264" {
|
||||||
|
return String(raw || "").toLowerCase() === "h264" ? "h264" : "mjpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameFormatLabel(format: unknown): string {
|
||||||
|
if (typeof format === "number") {
|
||||||
|
return format === 1 ? "jpeg(1)" :
|
||||||
|
format === 2 ? "blocks(2)" :
|
||||||
|
format === 3 ? "blocks_raw(3)" :
|
||||||
|
format === 4 ? "h264(4)" :
|
||||||
|
`unknown(${format})`;
|
||||||
|
}
|
||||||
|
return String(format || "jpeg").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function h264StreamStartFlags(bytes: Uint8Array): { sps: boolean; pps: boolean; idr: boolean } {
|
||||||
|
const flags = { sps: false, pps: false, idr: false };
|
||||||
|
for (let i = 0; i + 4 < bytes.length; i++) {
|
||||||
|
let startCodeLen = 0;
|
||||||
|
if (bytes[i] === 0x00 && bytes[i + 1] === 0x00 && bytes[i + 2] === 0x01) {
|
||||||
|
startCodeLen = 3;
|
||||||
|
} else if (
|
||||||
|
i + 4 < bytes.length &&
|
||||||
|
bytes[i] === 0x00 &&
|
||||||
|
bytes[i + 1] === 0x00 &&
|
||||||
|
bytes[i + 2] === 0x00 &&
|
||||||
|
bytes[i + 3] === 0x01
|
||||||
|
) {
|
||||||
|
startCodeLen = 4;
|
||||||
|
}
|
||||||
|
if (!startCodeLen) continue;
|
||||||
|
const nalIndex = i + startCodeLen;
|
||||||
|
if (nalIndex >= bytes.length) break;
|
||||||
|
const nalType = bytes[nalIndex] & 0x1f;
|
||||||
|
if (nalType === 5) flags.idr = true;
|
||||||
|
if (nalType === 7) flags.sps = true;
|
||||||
|
if (nalType === 8) flags.pps = true;
|
||||||
|
i = nalIndex;
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSegmentFiles(clientId: string, recordingId: string): RdRecordingSummary["files"] {
|
||||||
|
const dir = path.join(clientRecordingDir(clientId), recordingId);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const prefix = `${recordingId}-`;
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((name) => name.startsWith(prefix) && (name.endsWith(".mp4") || name.endsWith(".webm")))
|
||||||
|
.sort()
|
||||||
|
.map((name) => {
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(filePath).size;
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
downloadUrl: `/api/clients/${encodeURIComponent(clientId)}/rd/recordings/${encodeURIComponent(recordingId)}/${encodeURIComponent(name)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(recording: ActiveRecording): RdRecordingSummary {
|
||||||
|
return {
|
||||||
|
id: recording.id,
|
||||||
|
clientId: recording.clientId,
|
||||||
|
status: recording.status,
|
||||||
|
startedAt: recording.startedAt,
|
||||||
|
stoppedAt: recording.stoppedAt,
|
||||||
|
requestedByUserId: recording.requestedByUserId,
|
||||||
|
requestedByUsername: recording.requestedByUsername,
|
||||||
|
sourceFps: recording.sourceFps,
|
||||||
|
targetFps: recording.targetFps,
|
||||||
|
segmentSeconds: recording.segmentSeconds,
|
||||||
|
compact: recording.compact,
|
||||||
|
encodingMode: recording.encodingMode,
|
||||||
|
framesWritten: recording.framesWritten,
|
||||||
|
framesDropped: recording.framesDropped,
|
||||||
|
framesSkipped: recording.framesSkipped,
|
||||||
|
bytesWritten: recording.bytesWritten,
|
||||||
|
error: recording.error,
|
||||||
|
files: listSegmentFiles(recording.clientId, recording.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMetadata(recording: ActiveRecording): void {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(metadataPath(recording.dir), JSON.stringify(toSummary(recording), null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[rd-recording] failed to write metadata for ${recording.clientId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastStatus(clientId: string): void {
|
||||||
|
const status = getRemoteDesktopRecordingStatus(clientId);
|
||||||
|
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
|
||||||
|
safeSendViewer(session.viewer, { type: "recording_status", recording: status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeRecording(recording: ActiveRecording, status: RdRecordingStatus, error?: string): void {
|
||||||
|
if (recording.finalized) return;
|
||||||
|
recording.finalized = true;
|
||||||
|
if (status === "stopped" && recording.framesWritten === 0) {
|
||||||
|
status = "failed";
|
||||||
|
error = error || "No compatible video frames were received while recording.";
|
||||||
|
}
|
||||||
|
recording.status = status;
|
||||||
|
recording.stoppedAt = Date.now();
|
||||||
|
if (error) recording.error = error;
|
||||||
|
activeRecordings.delete(recording.clientId);
|
||||||
|
writeMetadata(recording);
|
||||||
|
logger.info(`[rd-recording] ${status} client=${recording.clientId} id=${recording.id} frames=${recording.framesWritten} dropped=${recording.framesDropped} skipped=${recording.framesSkipped}`);
|
||||||
|
broadcastStatus(recording.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRemoteDesktopRecording(options: StartOptions): RdRecordingSummary {
|
||||||
|
const existing = activeRecordings.get(options.clientId);
|
||||||
|
if (existing) return toSummary(existing);
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const dir = path.join(clientRecordingDir(options.clientId), id);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const inputCodec = recordingInputCodec(options.inputCodec);
|
||||||
|
const requestedOutputFps = requestedFps(options.fps);
|
||||||
|
const sourceFps = inputCodec === "h264" ? h264InputFps(options.sourceFps) : undefined;
|
||||||
|
const fps = inputCodec === "h264"
|
||||||
|
? Math.min(requestedOutputFps || sourceFps || h264InputFps(), sourceFps || h264InputFps())
|
||||||
|
: targetFps(options.fps);
|
||||||
|
const h264FpsLimited = inputCodec === "h264" && !!requestedOutputFps && !!sourceFps && requestedOutputFps < sourceFps;
|
||||||
|
const seconds = segmentSeconds();
|
||||||
|
const compact = !!options.compact;
|
||||||
|
const encodingMode: ActiveRecording["encodingMode"] = compact
|
||||||
|
? "compact"
|
||||||
|
: inputCodec === "h264" && !h264FpsLimited
|
||||||
|
? "copy"
|
||||||
|
: "transcode";
|
||||||
|
const outputFile = path.join(dir, `${id}-001.mp4`);
|
||||||
|
const h264InputArgs = [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-fflags",
|
||||||
|
"+genpts",
|
||||||
|
"-r",
|
||||||
|
String(sourceFps || fps),
|
||||||
|
"-f",
|
||||||
|
"h264",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-an",
|
||||||
|
];
|
||||||
|
const args = inputCodec === "h264"
|
||||||
|
? encodingMode === "copy"
|
||||||
|
? [
|
||||||
|
...h264InputArgs,
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
outputFile,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...h264InputArgs,
|
||||||
|
...(h264FpsLimited ? ["-vf", `fps=${fps}`] : []),
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
compact ? compactPreset() : "veryfast",
|
||||||
|
"-tune",
|
||||||
|
"zerolatency",
|
||||||
|
...(compact ? ["-crf", String(compactCrf())] : ["-b:v", videoBitrate()]),
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
outputFile,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-f",
|
||||||
|
"image2pipe",
|
||||||
|
"-framerate",
|
||||||
|
String(fps),
|
||||||
|
"-vcodec",
|
||||||
|
"mjpeg",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-an",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
compact ? compactPreset() : "veryfast",
|
||||||
|
"-tune",
|
||||||
|
"zerolatency",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
...(compact ? ["-crf", String(compactCrf())] : ["-b:v", videoBitrate()]),
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
outputFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
const child = spawn(ffmpegPath(), args, { windowsHide: true });
|
||||||
|
const recording: ActiveRecording = {
|
||||||
|
id,
|
||||||
|
clientId: options.clientId,
|
||||||
|
status: "starting",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
requestedByUserId: options.requestedByUserId,
|
||||||
|
requestedByUsername: options.requestedByUsername,
|
||||||
|
sourceFps,
|
||||||
|
targetFps: fps,
|
||||||
|
segmentSeconds: seconds,
|
||||||
|
framesWritten: 0,
|
||||||
|
framesDropped: 0,
|
||||||
|
framesSkipped: 0,
|
||||||
|
bytesWritten: 0,
|
||||||
|
dir,
|
||||||
|
process: child,
|
||||||
|
inputCodec,
|
||||||
|
compact,
|
||||||
|
encodingMode,
|
||||||
|
h264Started: false,
|
||||||
|
lastFrameAt: 0,
|
||||||
|
lastDebugAt: 0,
|
||||||
|
stderr: "",
|
||||||
|
writeBlocked: false,
|
||||||
|
finalized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
activeRecordings.set(options.clientId, recording);
|
||||||
|
writeMetadata(recording);
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
recording.stderr = `${recording.stderr}${chunk.toString()}`.slice(-4000);
|
||||||
|
});
|
||||||
|
child.stdin.on("drain", () => {
|
||||||
|
recording.writeBlocked = false;
|
||||||
|
});
|
||||||
|
child.stdin.on("error", (err) => {
|
||||||
|
if (!recording.finalized) {
|
||||||
|
recording.error = (err as Error).message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("error", (err) => {
|
||||||
|
finalizeRecording(recording, "failed", `ffmpeg failed to start: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
if (recording.finalized) return;
|
||||||
|
const ok = recording.status === "stopping" || code === 0;
|
||||||
|
const err = ok ? undefined : (recording.stderr || `ffmpeg exited with code=${code} signal=${signal || ""}`).trim();
|
||||||
|
logger.debug(`[rd-recording] ffmpeg exit client=${recording.clientId} id=${recording.id} code=${code ?? "null"} signal=${signal || ""} frames=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped}${recording.stderr ? ` stderr=${recording.stderr.slice(-500)}` : ""}`);
|
||||||
|
finalizeRecording(recording, ok ? "stopped" : "failed", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputCodec = encodingMode === "copy" ? "copy" : "libx264";
|
||||||
|
const outputRate = encodingMode === "copy" ? "source" : compact ? `crf${compactCrf()}` : videoBitrate();
|
||||||
|
const outputPreset = encodingMode === "copy" ? "source" : compact ? compactPreset() : "veryfast";
|
||||||
|
logger.info(`[rd-recording] started client=${options.clientId} id=${id} source_fps=${sourceFps || "n/a"} target_fps=${fps} fps_limited=${h264FpsLimited} segment=${seconds}s input=${inputCodec} output=mp4 mode=${encodingMode} codec=${outputCodec} bitrate=${outputRate} preset=${outputPreset} file=${outputFile}`);
|
||||||
|
broadcastStatus(options.clientId);
|
||||||
|
return toSummary(recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRemoteDesktopFrame(clientId: string, header: any, bytes: Uint8Array): void {
|
||||||
|
const recording = activeRecordings.get(clientId);
|
||||||
|
if (!recording || recording.status === "stopping") return;
|
||||||
|
|
||||||
|
const rawFormat = header?.format ?? "jpeg";
|
||||||
|
const format = typeof rawFormat === "number" ? rawFormat : String(rawFormat).toLowerCase();
|
||||||
|
const isH264 = format === 4 || format === "h264";
|
||||||
|
const isJpeg = format === 1 || format === "jpeg" || format === "jpg";
|
||||||
|
if ((recording.inputCodec === "h264" && !isH264) || (recording.inputCodec === "mjpeg" && !isJpeg)) {
|
||||||
|
recording.framesSkipped += 1;
|
||||||
|
if (recording.framesSkipped <= 5 || recording.framesSkipped % 60 === 0) {
|
||||||
|
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=unsupported_format expected=${recording.inputCodec} format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recording.inputCodec === "h264" && !recording.h264Started) {
|
||||||
|
const flags = h264StreamStartFlags(bytes);
|
||||||
|
if (!flags.sps || !flags.pps) {
|
||||||
|
recording.framesSkipped += 1;
|
||||||
|
if (recording.framesSkipped <= 5 || recording.framesSkipped % 60 === 0) {
|
||||||
|
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=waiting_for_h264_headers format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} sps=${flags.sps} pps=${flags.pps} idr=${flags.idr} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording.h264Started = true;
|
||||||
|
logger.debug(`[rd-recording] h264 stream start client=${clientId} id=${recording.id} bytes=${bytes.byteLength} sps=${flags.sps} pps=${flags.pps} idr=${flags.idr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const shouldThrottle = recording.inputCodec !== "h264";
|
||||||
|
const minInterval = Math.floor(1000 / Math.max(1, recording.targetFps));
|
||||||
|
if (shouldThrottle && recording.lastFrameAt && now - recording.lastFrameAt < minInterval) {
|
||||||
|
recording.framesSkipped += 1;
|
||||||
|
if (recording.framesSkipped <= 5 || recording.framesSkipped % 120 === 0) {
|
||||||
|
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=fps_throttle format=${frameFormatLabel(rawFormat)} deltaMs=${now - recording.lastFrameAt} minMs=${minInterval} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recording.process.stdin.writable) {
|
||||||
|
recording.framesDropped += 1;
|
||||||
|
if (recording.framesDropped <= 5 || recording.framesDropped % 30 === 0) {
|
||||||
|
logger.debug(`[rd-recording] drop frame client=${clientId} id=${recording.id} reason=stdin_closed writable=${recording.process.stdin.writable} dropped=${recording.framesDropped} written=${recording.framesWritten}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recording.inputCodec !== "h264" && recording.writeBlocked) {
|
||||||
|
recording.framesDropped += 1;
|
||||||
|
if (recording.framesDropped <= 5 || recording.framesDropped % 30 === 0) {
|
||||||
|
logger.debug(`[rd-recording] drop frame client=${clientId} id=${recording.id} reason=stdin_backpressure writable=${recording.process.stdin.writable} dropped=${recording.framesDropped} written=${recording.framesWritten}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recording.status = "recording";
|
||||||
|
recording.lastFrameAt = now;
|
||||||
|
recording.framesWritten += 1;
|
||||||
|
recording.bytesWritten += bytes.byteLength;
|
||||||
|
recording.writeBlocked = !recording.process.stdin.write(Buffer.from(bytes));
|
||||||
|
if (recording.framesWritten === 1 || now - recording.lastDebugAt >= 5000) {
|
||||||
|
recording.lastDebugAt = now;
|
||||||
|
logger.debug(`[rd-recording] write frame client=${clientId} id=${recording.id} format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} written=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped} stdinBlocked=${recording.writeBlocked}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRemoteDesktopRecording(clientId: string, reason = "stopped"): RdRecordingSummary | null {
|
||||||
|
const recording = activeRecordings.get(clientId);
|
||||||
|
if (!recording) return null;
|
||||||
|
logger.debug(`[rd-recording] stop requested client=${clientId} id=${recording.id} reason=${reason} status=${recording.status} frames=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped}`);
|
||||||
|
recording.status = "stopping";
|
||||||
|
recording.error = reason === "stopped" ? recording.error : reason;
|
||||||
|
writeMetadata(recording);
|
||||||
|
try {
|
||||||
|
recording.process.stdin.end();
|
||||||
|
} catch {}
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
if (!recording.finalized) {
|
||||||
|
try {
|
||||||
|
recording.process.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
finalizeRecording(recording, "failed", recording.error || "ffmpeg did not stop cleanly");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
if (typeof (killTimer as any).unref === "function") {
|
||||||
|
(killTimer as any).unref();
|
||||||
|
}
|
||||||
|
broadcastStatus(clientId);
|
||||||
|
return toSummary(recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteDesktopRecordingStatus(clientId: string): RdRecordingSummary | null {
|
||||||
|
const active = activeRecordings.get(clientId);
|
||||||
|
if (active) return toSummary(active);
|
||||||
|
return listRemoteDesktopRecordings(clientId)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRemoteDesktopRecordings(clientId: string): RdRecordingSummary[] {
|
||||||
|
const active = activeRecordings.get(clientId);
|
||||||
|
const root = clientRecordingDir(clientId);
|
||||||
|
const summaries: RdRecordingSummary[] = [];
|
||||||
|
if (fs.existsSync(root)) {
|
||||||
|
for (const id of fs.readdirSync(root)) {
|
||||||
|
const meta = path.join(root, id, "recording.json");
|
||||||
|
if (!fs.existsSync(meta)) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(meta, "utf8")) as RdRecordingSummary;
|
||||||
|
if (parsed.clientId !== clientId || parsed.id !== id) continue;
|
||||||
|
parsed.files = listSegmentFiles(clientId, id);
|
||||||
|
summaries.push(parsed);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active && !summaries.some((item) => item.id === active.id)) {
|
||||||
|
summaries.push(toSummary(active));
|
||||||
|
}
|
||||||
|
return summaries.sort((a, b) => b.startedAt - a.startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteDesktopRecordingFile(
|
||||||
|
clientId: string,
|
||||||
|
recordingId: string,
|
||||||
|
fileName: string,
|
||||||
|
): { path: string; size: number } | null {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(recordingId)) return null;
|
||||||
|
if (!new RegExp(`^${recordingId.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}-\\d+\\.(mp4|webm)$`).test(fileName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const filePath = path.join(clientRecordingDir(clientId), recordingId, fileName);
|
||||||
|
const root = path.resolve(clientRecordingDir(clientId), recordingId);
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (!resolved.startsWith(root + path.sep)) return null;
|
||||||
|
if (!fs.existsSync(resolved)) return null;
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (!stat.isFile()) return null;
|
||||||
|
return { path: resolved, size: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAllRemoteDesktopRecordings(reason = "server_shutdown"): void {
|
||||||
|
for (const clientId of Array.from(activeRecordings.keys())) {
|
||||||
|
stopRemoteDesktopRecording(clientId, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { authenticateRequest } from "../../auth";
|
||||||
|
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
|
||||||
|
import {
|
||||||
|
getRemoteDesktopRecordingFile,
|
||||||
|
getRemoteDesktopRecordingStatus,
|
||||||
|
listRemoteDesktopRecordings,
|
||||||
|
} from "../rd-recording";
|
||||||
|
|
||||||
|
type RdRecordingRouteDeps = {
|
||||||
|
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function forbiddenResponse(error: unknown): Response {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorize(req: Request, clientId: string): Promise<Response | null> {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
try {
|
||||||
|
requireClientAccess(user, clientId);
|
||||||
|
requireFeatureAccess(user, "remote_desktop");
|
||||||
|
} catch (error) {
|
||||||
|
return forbiddenResponse(error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoteDesktopRecordingRoutes(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
deps: RdRecordingRouteDeps,
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const listMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/rd\/recordings$/);
|
||||||
|
if (listMatch && req.method === "GET") {
|
||||||
|
const clientId = decodeURIComponent(listMatch[1]);
|
||||||
|
const denied = await authorize(req, clientId);
|
||||||
|
if (denied) return denied;
|
||||||
|
return Response.json({
|
||||||
|
active: getRemoteDesktopRecordingStatus(clientId),
|
||||||
|
items: listRemoteDesktopRecordings(clientId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/rd\/recordings\/([^/]+)\/([^/]+)$/);
|
||||||
|
if (fileMatch && req.method === "GET") {
|
||||||
|
const clientId = decodeURIComponent(fileMatch[1]);
|
||||||
|
const recordingId = decodeURIComponent(fileMatch[2]);
|
||||||
|
const fileName = decodeURIComponent(fileMatch[3]);
|
||||||
|
const denied = await authorize(req, clientId);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const file = getRemoteDesktopRecordingFile(clientId, recordingId, fileName);
|
||||||
|
if (!file) return new Response("Not found", { status: 404 });
|
||||||
|
const contentType = fileName.toLowerCase().endsWith(".mp4") ? "video/mp4" : "video/webm";
|
||||||
|
|
||||||
|
return new Response(Bun.file(file.path), {
|
||||||
|
headers: {
|
||||||
|
...deps.secureHeaders(contentType),
|
||||||
|
"Content-Length": String(file.size),
|
||||||
|
"Content-Disposition": `attachment; filename="${fileName.replace(/"/g, "")}"`,
|
||||||
|
"Cache-Control": "no-store, private",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType }
|
|||||||
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
||||||
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
||||||
import { clearThumbnail } from "../../thumbnails";
|
import { clearThumbnail } from "../../thumbnails";
|
||||||
|
import { stopRemoteDesktopRecording } from "../rd-recording";
|
||||||
|
|
||||||
const OFFLINE_GRACE_MS = (() => {
|
const OFFLINE_GRACE_MS = (() => {
|
||||||
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
|
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
|
||||||
@@ -991,6 +992,7 @@ export function handleWebSocketClose(
|
|||||||
deps.rdStreamingState.delete(clientId);
|
deps.rdStreamingState.delete(clientId);
|
||||||
deps.hvncStreamingState.delete(clientId);
|
deps.hvncStreamingState.delete(clientId);
|
||||||
deps.webcamStreamingState.delete(clientId);
|
deps.webcamStreamingState.delete(clientId);
|
||||||
|
stopRemoteDesktopRecording(clientId, "client disconnected");
|
||||||
logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`);
|
logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`);
|
||||||
|
|
||||||
if (role === "client" && currentClient) {
|
if (role === "client" && currentClient) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function checkOperatorAccess(
|
|||||||
|
|
||||||
type AuthenticatedUser = {
|
type AuthenticatedUser = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
username: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ async function tryClientViewerUpgrade(
|
|||||||
ip: getRequestIp(req, server),
|
ip: getRequestIp(req, server),
|
||||||
userRole: user.role,
|
userRole: user.role,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
};
|
};
|
||||||
if (route.sessionId) {
|
if (route.sessionId) {
|
||||||
data.sessionId = route.sessionId();
|
data.sessionId = route.sessionId();
|
||||||
@@ -219,6 +221,7 @@ async function tryGlobalViewerUpgrade(
|
|||||||
ip: getRequestIp(req, server),
|
ip: getRequestIp(req, server),
|
||||||
userRole: user.role,
|
userRole: user.role,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import {
|
|||||||
lookupP2PSession,
|
lookupP2PSession,
|
||||||
type P2PSession,
|
type P2PSession,
|
||||||
} from "./webrtc-p2p-sessions";
|
} from "./webrtc-p2p-sessions";
|
||||||
|
import {
|
||||||
|
getRemoteDesktopRecordingStatus,
|
||||||
|
recordRemoteDesktopFrame,
|
||||||
|
startRemoteDesktopRecording,
|
||||||
|
stopRemoteDesktopRecording,
|
||||||
|
} from "./rd-recording";
|
||||||
|
|
||||||
let _cachedInjectionDll: Uint8Array | null = null;
|
let _cachedInjectionDll: Uint8Array | null = null;
|
||||||
let _dllCachePath: string | null = null;
|
let _dllCachePath: string | null = null;
|
||||||
@@ -161,6 +167,7 @@ export const rdStreamingState = new Map<string, {
|
|||||||
codec: string;
|
codec: string;
|
||||||
duplication: boolean;
|
duplication: boolean;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
|
lastFps: number;
|
||||||
}>();
|
}>();
|
||||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||||
const RD_INPUT_TTL_MS = 10_000;
|
const RD_INPUT_TTL_MS = 10_000;
|
||||||
@@ -314,7 +321,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0 };
|
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||||
|
|
||||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
@@ -365,6 +372,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||||
.filter(s => s.id !== ws.data.sessionId);
|
.filter(s => s.id !== ws.data.sessionId);
|
||||||
if (otherViewers.length === 0) {
|
if (otherViewers.length === 0) {
|
||||||
|
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||||
sendDesktopCommand(target, "desktop_stop", {});
|
sendDesktopCommand(target, "desktop_stop", {});
|
||||||
sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
|
sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
|
||||||
if (state.isStreaming) {
|
if (state.isStreaming) {
|
||||||
@@ -380,6 +388,46 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "desktop_record_start": {
|
||||||
|
if (!state.isStreaming) {
|
||||||
|
safeSendViewer(ws, {
|
||||||
|
type: "recording_status",
|
||||||
|
recording: null,
|
||||||
|
error: "Start the remote desktop stream before recording.",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const inputCodec = String(state.codec || "").toLowerCase() === "h264" ? "h264" : "mjpeg";
|
||||||
|
const compact = !!(payload as any).compact;
|
||||||
|
const requestedFps = Number((payload as any).fps) || 0;
|
||||||
|
logger.debug(`[rd] recording start requested client=${clientId} current_quality=${state.quality || 90} current_codec=${state.codec || "(default)"} current_duplication=${!!state.duplication} recorder_input=${inputCodec} compact=${compact} requested_fps=${requestedFps || "source"} source_fps=${state.lastFps || 0} stream_unchanged=true`);
|
||||||
|
if (inputCodec === "h264") {
|
||||||
|
sendDesktopCommand(target, "desktop_request_keyframe", {
|
||||||
|
reason: "server_recording",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const recording = startRemoteDesktopRecording({
|
||||||
|
clientId,
|
||||||
|
requestedByUserId: ws.data.userId,
|
||||||
|
requestedByUsername: (ws.data as any).username,
|
||||||
|
fps: requestedFps || undefined,
|
||||||
|
sourceFps: inputCodec === "h264" ? (state.lastFps || undefined) : undefined,
|
||||||
|
inputCodec,
|
||||||
|
compact,
|
||||||
|
});
|
||||||
|
safeSendViewer(ws, { type: "recording_status", recording });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "desktop_record_stop": {
|
||||||
|
logger.debug(`[rd] recording stop requested client=${clientId} current_quality=${state.quality || 90} current_codec=${state.codec || "(default)"} current_duplication=${!!state.duplication}`);
|
||||||
|
const recording = stopRemoteDesktopRecording(clientId, "stopped");
|
||||||
|
safeSendViewer(ws, { type: "recording_status", recording: recording || getRemoteDesktopRecordingStatus(clientId) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "desktop_record_status": {
|
||||||
|
safeSendViewer(ws, { type: "recording_status", recording: getRemoteDesktopRecordingStatus(clientId) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "desktop_select_display": {
|
case "desktop_select_display": {
|
||||||
const newDisplay = Number(payload.display) || 0;
|
const newDisplay = Number(payload.display) || 0;
|
||||||
if (state.display !== newDisplay) {
|
if (state.display !== newDisplay) {
|
||||||
@@ -593,17 +641,30 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
const clientId = payload.clientId as string;
|
const clientId = payload.clientId as string;
|
||||||
const header = payload.header;
|
const header = payload.header;
|
||||||
const bytes = payload.data as Uint8Array;
|
const bytes = payload.data as Uint8Array;
|
||||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0 };
|
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||||
if (!state.isStreaming) {
|
if (!state.isStreaming) {
|
||||||
rdStreamingState.set(clientId, { ...state, isStreaming: true });
|
state.isStreaming = true;
|
||||||
}
|
}
|
||||||
|
const frameFps = Number(header?.fps) || 0;
|
||||||
|
if (frameFps > 0) state.lastFps = frameFps;
|
||||||
|
rdStreamingState.set(clientId, state);
|
||||||
|
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header?: any): boolean {
|
||||||
|
const frameFps = Number(header?.fps) || 0;
|
||||||
|
if (frameFps > 0) {
|
||||||
|
const state = rdStreamingState.get(clientId) || { isStreaming: true, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||||
|
state.lastFps = frameFps;
|
||||||
|
rdStreamingState.set(clientId, state);
|
||||||
|
}
|
||||||
|
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||||
const buf = buildViewerFrameBuffer(bytes, header);
|
const buf = buildViewerFrameBuffer(bytes, header);
|
||||||
broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||||
}
|
}
|
||||||
|
|
||||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||||
const buf = buildViewerFrameBuffer(bytes, header);
|
return broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type SocketData = {
|
|||||||
ip?: string;
|
ip?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
wasKnown?: boolean;
|
wasKnown?: boolean;
|
||||||
autoTasksRan?: boolean;
|
autoTasksRan?: boolean;
|
||||||
autoDeploysRan?: boolean;
|
autoDeploysRan?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user