Compare commits
9 Commits
subnet-dis
...
forge-prog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689e574f7a | ||
|
|
df88d160cb | ||
|
|
615034554c | ||
|
|
9a884a80b6 | ||
|
|
6ab43a468b | ||
|
|
0c7b676e23 | ||
|
|
ebcb1e1210 | ||
|
|
d4ac5d435a | ||
| 1d36d21242 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -82,7 +82,7 @@ _*.txt
|
||||
/server/*cov*
|
||||
|
||||
# Local APK / test logs (not tracked)
|
||||
/agent-tablet-1.apk
|
||||
/agent-*.apk
|
||||
/server/web/test-output.txt
|
||||
|
||||
# Go build cache (local)
|
||||
|
||||
174
LAUNCH.bat
174
LAUNCH.bat
@@ -2,23 +2,52 @@
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Deck
|
||||
cd /d "%~dp0"
|
||||
set "REPO=%CD%"
|
||||
|
||||
echo.
|
||||
echo ================================================================
|
||||
echo AetherForge - One-Click Launch
|
||||
echo ================================================================
|
||||
echo Folder: %REPO%
|
||||
echo.
|
||||
|
||||
set "PREP=%REPO%\scripts\launch-prep.bat"
|
||||
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
|
||||
if exist "%PREP%" (
|
||||
call "%PREP%" "%REPO%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo LAUNCH prep failed - fix errors above and retry.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
|
||||
)
|
||||
|
||||
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
|
||||
echo.
|
||||
echo [Dry run] Prep steps OK - not starting tunnel or server.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||
set "ROOT="
|
||||
if exist "%CD%\AetherForge.exe" (
|
||||
set "ROOT=!CD!"
|
||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||
cd /d "%CD%\usb"
|
||||
set "ROOT=!CD!"
|
||||
) else (
|
||||
echo.
|
||||
echo ERROR: AetherForge.exe not found.
|
||||
echo Expected next to this script, or in usb\AetherForge.exe
|
||||
echo Run pack-usb.bat from the repo to build the portable bundle.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
|
||||
|
||||
:: No portable binary - dev control server from repo
|
||||
goto dev_server_launch
|
||||
|
||||
:portable_deck
|
||||
|
||||
if not exist "%ROOT%\AetherForge.exe" (
|
||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||
pause
|
||||
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
:: Check if Go is installed system-wide
|
||||
where go >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [Go] Using system Go installation.
|
||||
@@ -58,7 +86,6 @@ if not errorlevel 1 (
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
:: Go not found anywhere - skip optional tools, proceed directly to server
|
||||
echo.
|
||||
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
||||
echo.
|
||||
@@ -66,17 +93,11 @@ goto server_launch
|
||||
|
||||
:go_ready
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 2. Pin all Go caches to the USB so module downloads travel with you
|
||||
:: ----------------------------------------------------------------
|
||||
set "GOPATH=%ROOT%\toolchain\gopath"
|
||||
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
||||
set "GOCACHE=%ROOT%\toolchain\gocache"
|
||||
set "GOENV=off"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 3. Install optional Forge tools if missing (non-fatal)
|
||||
:: ----------------------------------------------------------------
|
||||
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
||||
echo [Tools] Installing garble...
|
||||
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||
|
||||
:server_launch
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 4. Ensure data directories exist
|
||||
:: ----------------------------------------------------------------
|
||||
echo.
|
||||
echo Ensuring data directories...
|
||||
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
||||
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
||||
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
|
||||
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
||||
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 5. Detect LAN IP for display
|
||||
:: ----------------------------------------------------------------
|
||||
set "SERVER_PORT=8989"
|
||||
set "CONFIG_FILE=%ROOT%\data\config.json"
|
||||
if exist "%CONFIG_FILE%" (
|
||||
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
|
||||
:lan_done
|
||||
set "LAN_IP=%LAN_IP: =%"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 6. Kill any stale server and tunnel processes
|
||||
:: ----------------------------------------------------------------
|
||||
echo Stopping stale processes...
|
||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo Login accounts: admin + comrade ^(passwords below after start^).
|
||||
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
|
||||
echo Press Ctrl+C to stop.
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
|
||||
echo Starting tunnel...
|
||||
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
||||
if exist "%CF_SCRIPT%" (
|
||||
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
|
||||
cd /d "%ROOT%"
|
||||
|
||||
:: Open browser after short delay
|
||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||
|
||||
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
|
||||
echo Starting server...
|
||||
set "AF_TUNNEL_EXTERNAL=1"
|
||||
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
||||
set "EC=!ERRORLEVEL!"
|
||||
|
||||
if exist "%ROOT%\data\cloudflared.pid" (
|
||||
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
|
||||
call :cleanup_tunnel "%ROOT%"
|
||||
goto server_stopped
|
||||
|
||||
:dev_server_launch
|
||||
echo.
|
||||
echo No AetherForge.exe - starting dev control server ^(repo^).
|
||||
echo ^(Run pack-usb.bat for portable USB bundle.^)
|
||||
echo.
|
||||
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||
set "DATA=%REPO%\data"
|
||||
set "DECK=%REPO%"
|
||||
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
|
||||
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
|
||||
|
||||
echo Ensuring data directories...
|
||||
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
|
||||
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
|
||||
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
|
||||
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
|
||||
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
|
||||
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
|
||||
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
|
||||
|
||||
set "SERVER_PORT=8989"
|
||||
set "CONFIG_FILE=%DATA%\config.json"
|
||||
if exist "%CONFIG_FILE%" (
|
||||
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
|
||||
if not "%%P"=="" set "SERVER_PORT=%%P"
|
||||
)
|
||||
)
|
||||
set "LAN_IP=localhost"
|
||||
|
||||
echo Stopping stale processes...
|
||||
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
|
||||
where go >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%REPO%\bin\miner-server.exe" (
|
||||
echo Building control server...
|
||||
cd /d "%REPO%\server"
|
||||
go mod download >nul 2>nul
|
||||
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||
if errorlevel 1 (
|
||||
cd /d "%REPO%"
|
||||
echo ERROR: Server build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%REPO%"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================
|
||||
echo STARTING CONTROL SERVER ^(dev^)
|
||||
echo ================================================================
|
||||
echo Dashboard: http://localhost:%SERVER_PORT%
|
||||
echo Data: %DATA%\
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
echo Starting tunnel...
|
||||
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
|
||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
|
||||
if exist "%CF_SCRIPT%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
|
||||
) else (
|
||||
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
|
||||
)
|
||||
echo.
|
||||
|
||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||
|
||||
echo Starting server...
|
||||
cd /d "%REPO%"
|
||||
set "AF_TUNNEL_EXTERNAL=1"
|
||||
"%REPO%\bin\miner-server.exe" -data "%DATA%"
|
||||
set "EC=!ERRORLEVEL!"
|
||||
|
||||
call :cleanup_tunnel "%DECK%"
|
||||
goto server_stopped
|
||||
|
||||
:cleanup_tunnel
|
||||
set "TROOT=%~1"
|
||||
if exist "%TROOT%\data\cloudflared.pid" (
|
||||
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
|
||||
taskkill /F /PID %%P >nul 2>nul
|
||||
)
|
||||
del "%ROOT%\data\cloudflared.pid" 2>nul
|
||||
del "%TROOT%\data\cloudflared.pid" 2>nul
|
||||
)
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
exit /b 0
|
||||
|
||||
:server_stopped
|
||||
echo.
|
||||
if "!EC!"=="0" (
|
||||
echo [Server] Stopped normally.
|
||||
) else (
|
||||
echo [Server] Exited with code !EC!.
|
||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
||||
echo If port %SERVER_PORT% is in use, close other server windows and retry.
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 sudo-jones-cmd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
29
README.md
29
README.md
@@ -316,35 +316,32 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe
|
||||
|
||||
**Requirements:** Windows 10/11 on control PC. Outbound internet to your pool.
|
||||
|
||||
### Simple start (deploy → test → mine)
|
||||
### Simple start
|
||||
|
||||
Three steps — no spread, no triple onion, no Probe & Join required:
|
||||
**Double-click `LAUNCH.bat` — that's it.**
|
||||
|
||||
1. **Calibrate** — set your XMR **wallet** and **pool** (SupportXMR or your upstream).
|
||||
2. **Forge → Simple mode → Deploy & Mine** — one click forges an in-process worker (`simple_deploy` baked in). Run the `.exe` **once** on each PC you own.
|
||||
3. **Command Deck** — status shows **Testing → Mining** (or a clear failure reason). Online with 0 H/s? Use **Run diagnostics** or **Restart mining** on the banner.
|
||||
One click pulls updates (when online), builds the dashboard UI, ensures data folders, starts the Cloudflare tunnel sidecar, launches the control server, and opens your browser to the Command Deck (default **http://localhost:8989**). No portable USB bundle? `LAUNCH.bat` builds and runs `bin\miner-server.exe` from the repo instead.
|
||||
|
||||
Honest scope: **deploy** here means C2 registration + mining tier probe on that host — not lateral spread or registry staging to other machines.
|
||||
After the deck is up: **Calibrate** wallet/pool, then **Forge → Simple mode → Deploy & Mine** on each PC you own. Honest scope: **deploy** means C2 registration + mining tier probe on that host — not lateral spread.
|
||||
|
||||
### Operator path (dev control PC)
|
||||
|
||||
1. Double-click **`devrun.bat`** in the project root.
|
||||
Installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets, and starts the server.
|
||||
1. **`LAUNCH.bat`** — same one-click path as above (preferred).
|
||||
2. Or double-click **`devrun.bat`** for a dev-focused window (installs Go/Node if missing, pull + UI build, compiles `bin\miner-server.exe`, live logs).
|
||||
|
||||
2. Browser opens **http://localhost:8989**
|
||||
3. Browser opens **http://localhost:8989**
|
||||
|
||||
3. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
|
||||
4. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
|
||||
|
||||
4. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
|
||||
5. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
|
||||
|
||||
5. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
|
||||
6. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
|
||||
|
||||
6. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
|
||||
7. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
|
||||
|
||||
7. **Crucible** → **Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
|
||||
|
||||
8. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
|
||||
8. **Crucible** → **Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
|
||||
|
||||
9. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
|
||||
### Portable USB command deck
|
||||
|
||||
1. Run **`pack-usb.bat`** from repo root (re-run after any code change)
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,24 @@ import (
|
||||
// real engine so handleMessage can call pool.SetJob without panicking.
|
||||
func newTestClient(t *testing.T) *AgentClient {
|
||||
t.Helper()
|
||||
SetPostureCollector(func() *PostureReport {
|
||||
return &PostureReport{}
|
||||
})
|
||||
miner.SetProbeExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("cmd.exe", "/c", "exit 1")
|
||||
})
|
||||
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
|
||||
return miner.ContainerRuntimeInfo{}
|
||||
})
|
||||
miner.SetWSLDetector(func() miner.WSLRuntimeInfo {
|
||||
return miner.WSLRuntimeInfo{}
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
SetPostureCollector(nil)
|
||||
miner.SetProbeExecCommand(nil)
|
||||
miner.SetRuntimeDetector(nil)
|
||||
miner.SetWSLDetector(nil)
|
||||
})
|
||||
b := config.GetBuiltinConfig()
|
||||
b.Threads = 1
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: b}
|
||||
|
||||
@@ -169,7 +169,6 @@ func main() {
|
||||
sharesFound++
|
||||
fmt.Printf(" ★ SHARE job=%-14s nonce=%s\n", jobID, nonce)
|
||||
})
|
||||
pool.Start()
|
||||
pool.SetJob(&job.Job{
|
||||
ID: "validate-001",
|
||||
Blob: testBlobHex,
|
||||
@@ -177,9 +176,11 @@ func main() {
|
||||
SeedHash: testSeedHex,
|
||||
Height: 3000000,
|
||||
})
|
||||
pool.Start()
|
||||
|
||||
fmt.Printf(" ✓ %d worker(s) started, job injected\n", *threads)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
pool.ResetHashCounter()
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
done := time.After(time.Duration(*seconds) * time.Second)
|
||||
elapsed := 0
|
||||
var finalHS float64
|
||||
@@ -187,20 +188,23 @@ loop:
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
elapsed += 5
|
||||
elapsed += 1
|
||||
hs := pool.HashesPerSecond()
|
||||
pool.ResetHashCounter()
|
||||
finalHS = hs
|
||||
fmt.Printf(" [%2ds] %8.1f H/s shares=%d\n", elapsed, hs, sharesFound)
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
if hs := pool.HashesPerSecond(); hs > finalHS {
|
||||
finalHS = hs
|
||||
}
|
||||
break loop
|
||||
}
|
||||
}
|
||||
pool.Stop()
|
||||
|
||||
if finalHS < 1 {
|
||||
fail(fmt.Sprintf("hashrate is 0 after %ds", *seconds), &ok)
|
||||
if finalHS < 1 && sharesFound == 0 {
|
||||
fail(fmt.Sprintf("hashrate is 0 and no shares after %ds", *seconds), &ok)
|
||||
} else {
|
||||
fmt.Printf("\n ✓ %.0f H/s total / %.0f H/s per thread\n", finalHS, finalHS/float64(*threads))
|
||||
if sharesFound > 0 {
|
||||
|
||||
@@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
|
||||
default:
|
||||
}
|
||||
start := time.Now()
|
||||
err := o.invokeTier(tier, hooks)
|
||||
err := o.invokeTier(ctx, tier, hooks)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTierChainSkipped) {
|
||||
@@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
|
||||
return "", ErrTierChainExhausted
|
||||
}
|
||||
|
||||
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
func (o *TierOrchestrator) invokeTier(ctx context.Context, tier LOTLTier, hooks TierHooks) error {
|
||||
switch tier {
|
||||
case TierDockerLoad:
|
||||
if hooks.StartDockerLoad == nil {
|
||||
@@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return hooks.StartDotnet()
|
||||
case TierWMI:
|
||||
attempt := RunWMITier(context.Background(), o.cfg)
|
||||
attempt := RunWMITier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if !attempt.OK {
|
||||
if attempt.Error == "" {
|
||||
@@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return nil
|
||||
case TierScheduledTask:
|
||||
attempt := RunScheduledTaskTier(context.Background(), o.cfg)
|
||||
attempt := RunScheduledTaskTier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if !attempt.OK {
|
||||
if attempt.Error == "" {
|
||||
@@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
|
||||
}
|
||||
return nil
|
||||
case TierGPUCompute:
|
||||
attempt := RunGPUComputeTier(context.Background(), o.cfg)
|
||||
attempt := RunGPUComputeTier(ctx, o.cfg)
|
||||
o.recordAttemptRecord(attempt)
|
||||
if attempt.OK {
|
||||
o.mu.Lock()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"server_url": "http://deck:8989",
|
||||
"worker_name": "tab-1",
|
||||
"worker_number": "tab-1",
|
||||
"server_url": "http://10.0.0.1:8989",
|
||||
"worker_name": "cleanup-node",
|
||||
"worker_number": "cleanup-node",
|
||||
"mining": {
|
||||
"enabled": false
|
||||
},
|
||||
"build_id": "bld-cross"
|
||||
"build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0"
|
||||
}
|
||||
66
devrun.bat
66
devrun.bat
@@ -73,7 +73,7 @@ echo Go installed.
|
||||
|
||||
:go_ready
|
||||
|
||||
:: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
|
||||
:: Garble + go-winres (Forge pipeline tools ??? failure is non-fatal)
|
||||
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
|
||||
where garble >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
@@ -131,6 +131,26 @@ echo Node.js installed.
|
||||
|
||||
:node_ready
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 2b: Git pull + UI build (shared with LAUNCH.bat)
|
||||
:: ============================================================
|
||||
if defined SKIP_FRONTEND (
|
||||
echo.
|
||||
echo Pulling latest ^(UI build skipped - Node.js unavailable^)...
|
||||
git -C "%ROOT%" pull origin main 2>nul
|
||||
if errorlevel 1 echo Note: git pull skipped or failed.
|
||||
) else (
|
||||
echo.
|
||||
echo Pulling latest + building UI...
|
||||
set "PREP=%ROOT%\scripts\launch-prep.bat"
|
||||
if exist "%PREP%" (
|
||||
call "%PREP%" "%ROOT%"
|
||||
if errorlevel 1 goto fatal_exit
|
||||
) else (
|
||||
echo WARNING: scripts\launch-prep.bat missing - skipping pull/UI prep.
|
||||
)
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 3: Data directories
|
||||
:: ============================================================
|
||||
@@ -142,44 +162,6 @@ if not exist "data\preps" mkdir "data\preps"
|
||||
if not exist "bin" mkdir "bin"
|
||||
echo OK: data\ and bin\
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 4: Frontend
|
||||
:: ============================================================
|
||||
if defined SKIP_FRONTEND goto skip_frontend
|
||||
echo [4/5] Building dashboard (server\web)...
|
||||
cd /d "%ROOT%\server\web"
|
||||
if not exist "node_modules" (
|
||||
echo npm install...
|
||||
call npm install
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: npm install failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
)
|
||||
echo npm run build...
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
echo ERROR: Frontend build failed.
|
||||
goto fatal_exit
|
||||
)
|
||||
cd /d "%ROOT%"
|
||||
echo Frontend built: server\web\dist
|
||||
goto frontend_done
|
||||
|
||||
:skip_frontend
|
||||
echo [4/5] Skipping frontend build (Node.js unavailable)
|
||||
if not exist "server\web\dist\index.html" (
|
||||
echo WARNING: No server\web\dist\index.html — dashboard may not load.
|
||||
)
|
||||
|
||||
:frontend_done
|
||||
if exist "server\web\dist\index.html" (
|
||||
if not exist "server\webroot" mkdir "server\webroot"
|
||||
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
|
||||
echo Copied dashboard to server\webroot
|
||||
)
|
||||
|
||||
:: ============================================================
|
||||
:: STEP 5: Server binary
|
||||
@@ -198,7 +180,7 @@ if errorlevel 1 (
|
||||
cd /d "%ROOT%"
|
||||
|
||||
if defined AETHERFORGE_RELEASE (
|
||||
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
|
||||
echo Release mode active ??? set AETHERFORGE_RELEASE=1 for server process.
|
||||
)
|
||||
|
||||
if not exist "bin\miner-server.exe" (
|
||||
@@ -208,7 +190,7 @@ if not exist "bin\miner-server.exe" (
|
||||
echo Server binary: bin\miner-server.exe
|
||||
|
||||
:: ============================================================
|
||||
:: LAUNCH (foreground — logs stay in this window)
|
||||
:: LAUNCH (foreground ??? logs stay in this window)
|
||||
:: ============================================================
|
||||
echo.
|
||||
echo Stopping any previous miner-server.exe...
|
||||
@@ -261,7 +243,7 @@ goto end_pause
|
||||
:fatal_exit
|
||||
echo.
|
||||
echo ==============================================================
|
||||
echo LAUNCH FAILED — fix the errors above and run devrun.bat again.
|
||||
echo LAUNCH FAILED ??? fix the errors above and run devrun.bat again.
|
||||
echo ==============================================================
|
||||
echo.
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ echo [5/8] Launcher synced.
|
||||
|
||||
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
|
||||
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
|
||||
copy /y "%ROOT%\scripts\launch-prep.bat" "%USB%\scripts\" >nul
|
||||
if not exist "%USB%\data\cloudflared-token.txt" (
|
||||
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
|
||||
)
|
||||
|
||||
74
scripts/launch-prep.bat
Normal file
74
scripts/launch-prep.bat
Normal file
@@ -0,0 +1,74 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
set "PREP_ROOT=%~1"
|
||||
if "%PREP_ROOT%"=="" (
|
||||
echo [Prep] ERROR: launch-prep.bat requires repo root path.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Pulling latest from origin/main...
|
||||
if exist "%PREP_ROOT%\.git" (
|
||||
pushd "%PREP_ROOT%" >nul
|
||||
git pull origin main
|
||||
if errorlevel 1 (
|
||||
echo [Prep] Note: git pull skipped or failed ^(offline, no remote, or local changes^).
|
||||
) else (
|
||||
echo [Prep] Git pull finished.
|
||||
)
|
||||
popd >nul
|
||||
) else (
|
||||
echo [Prep] Skipped ^(not a git checkout^).
|
||||
)
|
||||
|
||||
if not exist "%PREP_ROOT%\server\web\package.json" (
|
||||
echo [Prep] No server\web\package.json - skipping UI build.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
set "PATH=C:\Program Files\nodejs;%PATH%"
|
||||
where npm >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [Prep] WARNING: npm not found - skipping UI build.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Building UI ^(server\web^)...
|
||||
cd /d "%PREP_ROOT%\server\web"
|
||||
if not exist "node_modules" (
|
||||
if exist "package-lock.json" (
|
||||
echo [Prep] npm ci...
|
||||
call npm ci
|
||||
) else (
|
||||
echo [Prep] npm install...
|
||||
call npm install
|
||||
)
|
||||
if errorlevel 1 (
|
||||
cd /d "%PREP_ROOT%"
|
||||
echo [Prep] ERROR: npm install failed.
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo [Prep] npm run build ^(this may take a few minutes^)...
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
cd /d "%PREP_ROOT%"
|
||||
echo [Prep] ERROR: Frontend build failed.
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%PREP_ROOT%"
|
||||
|
||||
if not exist "%PREP_ROOT%\server\webroot" mkdir "%PREP_ROOT%\server\webroot"
|
||||
echo [Prep] Syncing server\webroot...
|
||||
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
|
||||
|
||||
if exist "%PREP_ROOT%\usb" (
|
||||
if not exist "%PREP_ROOT%\usb\webroot" mkdir "%PREP_ROOT%\usb\webroot"
|
||||
echo [Prep] Syncing usb\webroot...
|
||||
xcopy /E /I /Y /Q "server\web\dist\*" "usb\webroot\" >nul
|
||||
)
|
||||
|
||||
echo [Prep] UI build complete.
|
||||
exit /b 0
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -345,8 +345,6 @@ func LoadConfig() *Config {
|
||||
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
|
||||
if cliPortExplicit {
|
||||
cfg.Port = cliPort
|
||||
} else {
|
||||
cfg.Port = cliPort
|
||||
}
|
||||
|
||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
||||
|
||||
@@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
|
||||
if err := json.Unmarshal([]byte(payload), &present); err != nil {
|
||||
t.Fatalf("unmarshal present keys: %v", err)
|
||||
}
|
||||
hydrateLegacyAIConfig(&incoming, []byte(payload))
|
||||
mergeConfigExplicit(dst, &incoming, present)
|
||||
}
|
||||
|
||||
@@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
|
||||
t.Fatal("test precondition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigExplicitLegacyAIFields(t *testing.T) {
|
||||
dst := DefaultConfig()
|
||||
dst.Server.AIEndpoint = ""
|
||||
dst.Server.AIDecisionIntervalSec = 0
|
||||
|
||||
applyMergeFromJSON(t, dst, `{"server":{"ai_local_endpoint":"http://local-ollama:11434","ai_interval_sec":30}}`)
|
||||
|
||||
if dst.Server.AIEndpoint != "http://local-ollama:11434" {
|
||||
t.Fatalf("expected Server.AIEndpoint to be hydrated from legacy, got %q", dst.Server.AIEndpoint)
|
||||
}
|
||||
if dst.Server.AIDecisionIntervalSec != 30 {
|
||||
t.Fatalf("expected Server.AIDecisionIntervalSec to be hydrated from legacy, got %d", dst.Server.AIDecisionIntervalSec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func apkFileName(req *BuildRequest) string {
|
||||
|
||||
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
|
||||
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
|
||||
h.apkBuildMu.Lock()
|
||||
defer h.apkBuildMu.Unlock()
|
||||
|
||||
if req.ScoutMode {
|
||||
ApplyApkScoutPreset(req)
|
||||
} else {
|
||||
@@ -253,8 +256,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
||||
}
|
||||
|
||||
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
|
||||
h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25)
|
||||
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false)
|
||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
|
||||
stopCompileProgress()
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
@@ -336,6 +340,7 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ package builder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyApkScoutPreset(t *testing.T) {
|
||||
@@ -254,3 +257,104 @@ func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
|
||||
t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPKAgentConcurrency(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
androidDir := filepath.Join(h.projectRoot, "android")
|
||||
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Channel to coordinate/delay the mock builds to assert serialization
|
||||
inBuildChan := make(chan struct{}, 2)
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
inBuildChan <- struct{}{}
|
||||
// Wait a small duration to keep the lock held, letting another call try to acquire it
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk concurrent"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return apk, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
req := &BuildRequest{
|
||||
WorkerName: fmt.Sprintf("node-%d", id),
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
CancelToken: fmt.Sprintf("cancel-token-%d", id),
|
||||
ApkMode: true,
|
||||
}
|
||||
resp, code, _ := h.buildAPKAgent(context.Background(), req)
|
||||
if code != 200 || !resp.Success {
|
||||
t.Errorf("concurrent build %d failed: code=%d resp=%+v", id, code, resp)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(inBuildChan)
|
||||
|
||||
// Since they are serialized, they should execute one after the other.
|
||||
if len(inBuildChan) != 2 {
|
||||
t.Fatalf("expected 2 builds to have run, got %d", len(inBuildChan))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPKAgentDatabaseFailureCleanup(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
// We close the database immediately so that InsertBuild fails
|
||||
_ = database.Close()
|
||||
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
androidDir := filepath.Join(h.projectRoot, "android")
|
||||
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
|
||||
apk := filepath.Join(buildDir, "agent-app-debug.apk")
|
||||
if err := os.WriteFile(apk, []byte("PK fake apk cleanup test"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return apk, nil
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "cleanup-node",
|
||||
ServerURL: "http://10.0.0.1:8989",
|
||||
CancelToken: "cleanup-test-token",
|
||||
ApkMode: true,
|
||||
}
|
||||
|
||||
// Capture existing files in builds dir
|
||||
buildsDir := filepath.Join(h.dataDir, "builds")
|
||||
_ = os.MkdirAll(buildsDir, 0755)
|
||||
|
||||
resp, code, _ := h.buildAPKAgent(context.Background(), req)
|
||||
if resp.Success || code == 200 {
|
||||
t.Fatalf("expected build to fail on DB write, but got success: code=%d", code)
|
||||
}
|
||||
|
||||
// Verify that the build directory under builds/ was cleaned up
|
||||
files, err := os.ReadDir(buildsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
var names []string
|
||||
for _, f := range files {
|
||||
names = append(names, f.Name())
|
||||
}
|
||||
t.Fatalf("expected builds directory to be empty after database failure cleanup, but found: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,17 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
||||
platforms := platformsForRequest(req)
|
||||
workerPaths := map[string]string{}
|
||||
total := len(platforms)
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
for i, p := range platforms {
|
||||
pct := 14 + (i*56)/total
|
||||
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
|
||||
startPct := 14 + (i*56)/total
|
||||
endPct := 14 + ((i+1)*56)/total
|
||||
if endPct > 71 {
|
||||
endPct = 71
|
||||
}
|
||||
stage := fmt.Sprintf("Compiling %s", p.Label())
|
||||
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, stage, startPct, endPct, obfuscated)
|
||||
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
stopCompileProgress()
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
|
||||
@@ -232,8 +232,12 @@ type Handler struct {
|
||||
|
||||
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
|
||||
apkBuildFn ApkBuildFunc
|
||||
|
||||
// apkBuildMu serializes parallel Android APK builds to prevent concurrent writes to the shared assets directory and concurrent gradle runs.
|
||||
apkBuildMu sync.Mutex
|
||||
}
|
||||
|
||||
|
||||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||
func (h *Handler) SetFleetSecret(secret string) {
|
||||
h.fleetSecret = secret
|
||||
@@ -690,15 +694,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
|
||||
platforms := platformsForRequest(req)
|
||||
p := platforms[0]
|
||||
h.setProgress(req.CancelToken, "Compiling agent", 20)
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent", 20, 71, obfuscated)
|
||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
stopCompileProgress()
|
||||
if err != nil {
|
||||
cleanupBuild()
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
workerName := filepath.Base(outputPath)
|
||||
finalPath := outputPath
|
||||
finalName := workerName
|
||||
|
||||
45
server/internal/builder/progress.go
Normal file
45
server/internal/builder/progress.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tickCompileProgress emits interpolated progress during long compile steps (garble can run 10+ minutes).
|
||||
// Returns a stop function; call it when the compile finishes.
|
||||
func (h *Handler) tickCompileProgress(ctx context.Context, token, stage string, startPct, capPct int, obfuscated bool) func() {
|
||||
if token == "" || capPct <= startPct {
|
||||
return func() {}
|
||||
}
|
||||
est := 3 * time.Minute
|
||||
if obfuscated {
|
||||
est = 12 * time.Minute
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.setProgress(token, stage, startPct)
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
start := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
elapsed := time.Since(start)
|
||||
ratio := float64(elapsed) / float64(est)
|
||||
if ratio > 0.92 {
|
||||
ratio = 0.92
|
||||
}
|
||||
pct := startPct + int(float64(capPct-startPct)*ratio)
|
||||
if pct >= capPct {
|
||||
pct = capPct - 1
|
||||
}
|
||||
h.setProgress(token, stage, pct)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -68,6 +68,9 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("AetherForge C2 starting...")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
projectRoot := findProjectRoot()
|
||||
cfg := LoadConfig()
|
||||
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
|
||||
@@ -278,8 +281,13 @@ func main() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -398,10 +406,13 @@ func main() {
|
||||
|
||||
// Start server
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
srv := &http.Server{Addr: addr, Handler: router}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("Server listening on %s", addr)
|
||||
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
|
||||
@@ -564,12 +575,11 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
|
||||
}
|
||||
|
||||
// Determine which top-level keys were explicitly present in the JSON payload.
|
||||
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
|
||||
// from the payload is treated as "not changed", not "set to false".
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
|
||||
hydrateLegacyAIConfig(&incoming, data)
|
||||
|
||||
mergeConfigExplicit(p.config, &incoming, presentKeys)
|
||||
|
||||
// Save to disk
|
||||
|
||||
@@ -118,6 +118,10 @@ export default function CrucibleExpandedOps({
|
||||
}
|
||||
}, [singleSelectedAgent?.mac_address, wolMac]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveDesktop(false);
|
||||
}, [singleSelectedAgent?.id]);
|
||||
|
||||
const dispatchOne = useCallback(
|
||||
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
|
||||
try {
|
||||
|
||||
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ForgeDispenseReveal from './ForgeDispenseReveal';
|
||||
|
||||
vi.mock('../../context/AmbientMusicContext', () => ({
|
||||
useModalAmbientDuck: () => {},
|
||||
}));
|
||||
|
||||
vi.mock('../../context/SoundContext', () => ({
|
||||
useSound: () => ({ play: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../DownloadButton', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
describe('ForgeDispenseReveal', () => {
|
||||
it('shows Forged title on successful forge', () => {
|
||||
render(
|
||||
<ForgeDispenseReveal
|
||||
result={{
|
||||
success: true,
|
||||
file_name: 'worker.exe',
|
||||
download_url: '/api/v1/builds/x/download',
|
||||
stealth_score: 72,
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Forged' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dispensed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
||||
<div className="forge-dispense-panel">
|
||||
<div className="forge-dispense-sigil" aria-hidden />
|
||||
<h2 id="forge-dispense-title" className="forge-dispense-title">
|
||||
Dispensed
|
||||
Forged
|
||||
</h2>
|
||||
<p className="forge-dispense-sub">
|
||||
{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature.
|
||||
|
||||
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import ForgeProgressBar, {
|
||||
FORGE_INITIAL_STAGE,
|
||||
isForgeProgressIndeterminate,
|
||||
} from './ForgeProgressBar';
|
||||
|
||||
describe('isForgeProgressIndeterminate', () => {
|
||||
it('is indeterminate before server reports progress', () => {
|
||||
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 0)).toBe(true);
|
||||
expect(isForgeProgressIndeterminate('', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('is determinate once server reports pct or stage advances', () => {
|
||||
expect(isForgeProgressIndeterminate('Compiling agent', 20)).toBe(false);
|
||||
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForgeProgressBar', () => {
|
||||
it('renders nothing when not building', () => {
|
||||
const { container } = render(
|
||||
<ForgeProgressBar building={false} stage="" progress={0} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows indeterminate track before server progress', () => {
|
||||
const { container } = render(
|
||||
<ForgeProgressBar building stage={FORGE_INITIAL_STAGE} progress={0} />,
|
||||
);
|
||||
expect(screen.getByText('…')).toBeInTheDocument();
|
||||
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows server stage and pct when progress is reported', () => {
|
||||
const { container } = render(
|
||||
<ForgeProgressBar building stage="Compiling agent" progress={42} />,
|
||||
);
|
||||
expect(screen.getByText('Compiling agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeNull();
|
||||
});
|
||||
});
|
||||
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
export const FORGE_INITIAL_STAGE = 'Initializing forge...';
|
||||
|
||||
interface Props {
|
||||
building: boolean;
|
||||
stage: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/** True while waiting for the first server-reported forge stage. */
|
||||
export function isForgeProgressIndeterminate(stage: string, progress: number): boolean {
|
||||
return progress === 0 && (stage === '' || stage === FORGE_INITIAL_STAGE);
|
||||
}
|
||||
|
||||
export default function ForgeProgressBar({ building, stage, progress }: Props) {
|
||||
if (!building) return null;
|
||||
|
||||
const indeterminate = isForgeProgressIndeterminate(stage, progress);
|
||||
const displayStage = stage || 'Initializing...';
|
||||
const pctLabel = indeterminate ? '…' : `${Math.round(progress)}%`;
|
||||
|
||||
return (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{displayStage}</span>
|
||||
<span className="forge-progress-pct">{pctLabel}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`forge-progress-track${indeterminate ? ' indeterminate' : ''}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : Math.round(progress)}
|
||||
aria-busy={indeterminate}
|
||||
aria-label={displayStage}
|
||||
>
|
||||
<div
|
||||
className="forge-progress-fill"
|
||||
style={indeterminate ? undefined : { width: `${progress}%` }}
|
||||
/>
|
||||
{!indeterminate && (
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -348,10 +348,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}${!isMobile && glowParticles ? ' layout--hacker-cursor' : ''}`}
|
||||
data-operator-deck={operatorDeckId(location.pathname)}
|
||||
>
|
||||
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
|
||||
{!isMobile && glowParticles && <CursorFire />}
|
||||
<AmbientBackground weather={pageWeather} />
|
||||
{glowParticles && <SacredGeometryLayer />}
|
||||
<nav className="sidebar sidebar--desktop desktop-only">
|
||||
|
||||
32
server/web/src/components/Visual/CursorFire.css
Normal file
32
server/web/src/components/Visual/CursorFire.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.cursor-hacker-fx {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.layout--hacker-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.layout--hacker-cursor a,
|
||||
.layout--hacker-cursor button,
|
||||
.layout--hacker-cursor input,
|
||||
.layout--hacker-cursor select,
|
||||
.layout--hacker-cursor textarea,
|
||||
.layout--hacker-cursor label,
|
||||
.layout--hacker-cursor [role='button'],
|
||||
.layout--hacker-cursor .nav-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cursor-hacker-fx {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.layout--hacker-cursor {
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,143 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import './CursorFire.css';
|
||||
|
||||
const BIT_CHARS = '01';
|
||||
const HEX_CHARS = '0123456789ABCDEF';
|
||||
const MAX_PARTICLES = 480;
|
||||
const EMIT_PER_FRAME = 12;
|
||||
const EMIT_WINDOW_MS = 140;
|
||||
const FONT_STACK = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number; // 1 → 0
|
||||
size: number;
|
||||
life: number;
|
||||
decay: number;
|
||||
char: string;
|
||||
fontSize: number;
|
||||
tint: 'cyan' | 'green';
|
||||
}
|
||||
|
||||
function pickChar(): string {
|
||||
if (Math.random() < 0.88) return BIT_CHARS[Math.floor(Math.random() * 2)];
|
||||
return HEX_CHARS[Math.floor(Math.random() * HEX_CHARS.length)];
|
||||
}
|
||||
|
||||
function colorForLife(life: number, tint: Particle['tint']): string {
|
||||
const a = Math.min(1, life * 1.05);
|
||||
if (tint === 'cyan') {
|
||||
if (life > 0.5) return `rgba(0, 255, 255, ${a})`;
|
||||
return `rgba(0, 240, 200, ${a * 0.92})`;
|
||||
}
|
||||
if (life > 0.5) return `rgba(80, 255, 160, ${a})`;
|
||||
return `rgba(0, 255, 120, ${a * 0.9})`;
|
||||
}
|
||||
|
||||
function glowForTint(tint: Particle['tint']): string {
|
||||
return tint === 'cyan' ? '#00e8f5' : '#00ff88';
|
||||
}
|
||||
|
||||
export default function CursorFire() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const mouse = useRef({ x: -9999, y: -9999, moved: false });
|
||||
const rafRef = useRef<number>(0);
|
||||
const mouse = useRef({ x: -9999, y: -9999 });
|
||||
const lastMoveRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
if (motionQuery.matches) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let running = true;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
|
||||
mouse.current = { x: e.clientX, y: e.clientY };
|
||||
lastMoveRef.current = performance.now();
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mousemove', onMove, { passive: true });
|
||||
|
||||
const stopOnReducedMotion = () => {
|
||||
if (!motionQuery.matches) return;
|
||||
running = false;
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
particles.current = [];
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
motionQuery.addEventListener('change', stopOnReducedMotion);
|
||||
|
||||
const emit = () => {
|
||||
if (performance.now() - lastMoveRef.current > EMIT_WINDOW_MS) return;
|
||||
const { x, y } = mouse.current;
|
||||
// Emit 6 particles per frame at cursor
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spread = 8;
|
||||
|
||||
for (let i = 0; i < EMIT_PER_FRAME; i++) {
|
||||
const spread = 14;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * spread,
|
||||
y: y + (Math.random() - 0.5) * (spread * 0.5),
|
||||
vx: (Math.random() - 0.5) * 1.2,
|
||||
vy: -(Math.random() * 2.8 + 1.8),
|
||||
y: y + (Math.random() - 0.5) * (spread * 0.45),
|
||||
vx: (Math.random() - 0.5) * 1.8,
|
||||
vy: -(Math.random() * 3.2 + 2.1),
|
||||
life: 1,
|
||||
size: Math.random() * 14 + 7,
|
||||
decay: Math.random() * 0.022 + 0.016,
|
||||
decay: Math.random() * 0.016 + 0.012,
|
||||
char: pickChar(),
|
||||
fontSize: Math.random() * 16 + 16,
|
||||
tint: Math.random() < 0.5 ? 'cyan' : 'green',
|
||||
});
|
||||
}
|
||||
// Cap particle count for perf
|
||||
if (particles.current.length > 400) {
|
||||
particles.current = particles.current.slice(-400);
|
||||
|
||||
if (particles.current.length > MAX_PARTICLES) {
|
||||
particles.current = particles.current.slice(-MAX_PARTICLES);
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
if (!running) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Additive blending makes overlapping particles look white-hot
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
emit();
|
||||
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
// Turbulent horizontal drift
|
||||
p.vx += (Math.random() - 0.5) * 0.35;
|
||||
// Slight drag on vx
|
||||
p.vx *= 0.97;
|
||||
// Upward acceleration (heat rises)
|
||||
p.vy -= 0.04;
|
||||
|
||||
p.vx += (Math.random() - 0.5) * 0.28;
|
||||
p.vx *= 0.96;
|
||||
p.vy -= 0.035;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= p.decay;
|
||||
// Particles shrink as they cool
|
||||
p.size *= 0.982;
|
||||
p.fontSize *= 0.985;
|
||||
|
||||
if (p.life <= 0 || p.size < 1) continue;
|
||||
if (p.life <= 0 || p.fontSize < 8) continue;
|
||||
alive.push(p);
|
||||
|
||||
const l = p.life;
|
||||
// Color temperature: white-yellow core → orange → red → dark red
|
||||
let r: number, g: number, b: number;
|
||||
if (l > 0.75) {
|
||||
// White-hot
|
||||
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
|
||||
} else if (l > 0.5) {
|
||||
// Yellow-orange
|
||||
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
|
||||
} else if (l > 0.25) {
|
||||
// Orange-red
|
||||
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
|
||||
} else {
|
||||
// Deep red, fading
|
||||
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
|
||||
}
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
|
||||
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
|
||||
grad.addColorStop(1, `rgba(0,0,0,0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
const glow = 10 + p.life * 18;
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.shadowColor = glowForTint(p.tint);
|
||||
ctx.font = `600 ${p.fontSize}px ${FONT_STACK}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = colorForLife(p.life, p.tint);
|
||||
ctx.fillText(p.char, p.x, p.y);
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
particles.current = alive;
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
@@ -116,22 +145,13 @@ export default function CursorFire() {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
running = false;
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
motionQuery.removeEventListener('change', stopOnReducedMotion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="cursor-fire-fx"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <canvas ref={canvasRef} className="cursor-hacker-fx" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
@@ -907,9 +907,29 @@ describe('AmbientBackground', () => {
|
||||
describe('CursorFire', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('mounts fullscreen canvas', () => {
|
||||
it('mounts fullscreen hacker-trail canvas', () => {
|
||||
const { container } = render(<CursorFire />);
|
||||
expect(container.querySelector('canvas')).toBeTruthy();
|
||||
const canvas = container.querySelector('canvas.cursor-hacker-fx');
|
||||
expect(canvas).toBeTruthy();
|
||||
expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('skips animation loop when prefers-reduced-motion', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
|
||||
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
render(<CursorFire />);
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
matchMediaSpy.mockRestore();
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -970,7 +990,7 @@ describe('Layout', () => {
|
||||
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts MatrixRain and CursorFire on Command Deck only', async () => {
|
||||
it('mounts MatrixRain on Command Deck only and CursorFire on all desktop deck pages', async () => {
|
||||
const { container: deck } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
||||
<Layout>
|
||||
@@ -980,7 +1000,7 @@ describe('Layout', () => {
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
||||
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
|
||||
expect(deck.querySelector('.cursor-hacker-fx')).toBeTruthy();
|
||||
});
|
||||
cleanup();
|
||||
|
||||
@@ -995,6 +1015,7 @@ describe('Layout', () => {
|
||||
expect(screen.getByText('crucible')).toBeInTheDocument();
|
||||
});
|
||||
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
||||
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
|
||||
expect(crucible.querySelector('.cursor-hacker-fx')).toBeTruthy();
|
||||
expect(crucible.querySelector('.layout--hacker-cursor')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,4 +37,21 @@ describe('fleetGroups', () => {
|
||||
saveFleetGroups(groups);
|
||||
expect(loadFleetGroups()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('generates unique fallback IDs for loaded groups missing an ID', () => {
|
||||
// Manually saving raw JSON objects without IDs to simulate legacy state
|
||||
const rawGroups = [
|
||||
{ name: 'Legacy Group 1', color: '#ff0000', agentIds: [] },
|
||||
{ name: 'Legacy Group 2', color: '#00ff00', agentIds: [] },
|
||||
];
|
||||
localStorage.setItem('aetherforge_fleet_groups', JSON.stringify(rawGroups));
|
||||
|
||||
const loaded = loadFleetGroups();
|
||||
expect(loaded).toHaveLength(2);
|
||||
expect(loaded[0].id).toBeDefined();
|
||||
expect(loaded[1].id).toBeDefined();
|
||||
expect(loaded[0].id).not.toBe(loaded[1].id);
|
||||
expect(loaded[0].id.startsWith('fg-')).toBe(true);
|
||||
expect(loaded[1].id.startsWith('fg-')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] {
|
||||
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
|
||||
: [];
|
||||
return {
|
||||
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
|
||||
id: typeof o.id === 'string' && o.id ? o.id : `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
|
||||
name,
|
||||
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
|
||||
agentIds,
|
||||
|
||||
@@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when failed_methods, services, atlas_skips, and vuln_findings are structurally identical but have different array references', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when failed_methods change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when services change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'stopped', start_type: 'auto' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when atlas_skips change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'failed-5-times' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when vuln_findings change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: true }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { Agent, AgentService } from '../types';
|
||||
import type { WSStatsUpdate } from '../types/ws';
|
||||
|
||||
/** Returns true when a stats_update payload would not change visible agent fields. */
|
||||
@@ -44,16 +44,17 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
|
||||
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
|
||||
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
|
||||
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false;
|
||||
if (u.failed_methods !== undefined && !failedMethodsEq(agent.failed_methods, u.failed_methods)) return false;
|
||||
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||
if (u.services !== undefined && !servicesEq(agent.services, u.services)) return false;
|
||||
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
|
||||
if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false;
|
||||
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
|
||||
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
|
||||
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
|
||||
if (u.atlas_skips !== undefined && !atlasSkipsEq(agent.atlas_skips, u.atlas_skips)) return false;
|
||||
if (u.vuln_findings !== undefined && !vulnFindingsEq(agent.vuln_findings, u.vuln_findings)) return false;
|
||||
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
|
||||
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
|
||||
if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false;
|
||||
@@ -84,6 +85,74 @@ function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('.
|
||||
return true;
|
||||
}
|
||||
|
||||
function failedMethodsEq(
|
||||
a?: { method: string; reason: string; at: string }[],
|
||||
b?: { method: string; reason: string; at: string }[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].method !== b[i].method || a[i].reason !== b[i].reason || a[i].at !== b[i].at) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function servicesEq(a?: AgentService[], b?: AgentService[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.name !== y.name ||
|
||||
x.display_name !== y.display_name ||
|
||||
x.status !== y.status ||
|
||||
x.start_type !== y.start_type
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function atlasSkipsEq(
|
||||
a?: { tier: string; condition: string; reason: string }[],
|
||||
b?: { tier: string; condition: string; reason: string }[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].tier !== b[i].tier || a[i].condition !== b[i].condition || a[i].reason !== b[i].reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function vulnFindingsEq(
|
||||
a?: import('../types/recon').VulnFinding[],
|
||||
b?: import('../types/recon').VulnFinding[]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.cve_id !== y.cve_id ||
|
||||
x.severity !== y.severity ||
|
||||
x.component !== y.component ||
|
||||
x.patched !== y.patched ||
|
||||
x.exploitable_in_fleet_context !== y.exploitable_in_fleet_context
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'presence_snapshot',
|
||||
|
||||
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
|
||||
const FORGE_POLL_MS = 1000;
|
||||
|
||||
/** Poll GET /api/v1/builder/progress/{token} while a forge is running. */
|
||||
export function useForgeProgressPoll(
|
||||
active: boolean,
|
||||
cancelTokenRef: React.RefObject<string>,
|
||||
) {
|
||||
const { startForge, endForge, setStage } = useForge();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
endForge();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
return;
|
||||
}
|
||||
startForge();
|
||||
|
||||
const token = cancelTokenRef.current;
|
||||
if (!token) return;
|
||||
|
||||
let alive = true;
|
||||
|
||||
const poll = async () => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const { authHeaders } = await import('../api/auth');
|
||||
const res = await fetch(`/api/v1/builder/progress/${encodeURIComponent(token)}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { stage?: string; pct?: number } = await res.json();
|
||||
const pct = typeof data.pct === 'number' ? data.pct : 0;
|
||||
if (alive && (data.stage || pct > 0)) {
|
||||
setStage(data.stage || 'Forging...', pct);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network hiccup — keep polling
|
||||
}
|
||||
if (alive) {
|
||||
timerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
}
|
||||
@@ -103,6 +103,9 @@ export default function ActivityFeedPage() {
|
||||
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
|
||||
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
|
||||
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score
|
||||
const seenShares = useRef<Set<string>>(new Set());
|
||||
const seenAlerts = useRef<Set<string>>(new Set());
|
||||
const aiInitialized = useRef(false);
|
||||
|
||||
// Build agent name lookup
|
||||
useEffect(() => {
|
||||
@@ -160,7 +163,7 @@ export default function ActivityFeedPage() {
|
||||
const prev = prevHashrates.current[agent.id];
|
||||
const cur = agent.hashrate_15m ?? 0;
|
||||
prevHashrates.current[agent.id] = cur;
|
||||
if (prev === undefined || prev <= 0) continue;
|
||||
if (prev === undefined) continue;
|
||||
const delta = cur - prev;
|
||||
// Only emit if ≥20% change AND at least 100 H/s delta
|
||||
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
|
||||
@@ -197,13 +200,38 @@ export default function ActivityFeedPage() {
|
||||
}, [agents, push]);
|
||||
|
||||
// ── New share events ───────────────────────────────────────────────────
|
||||
const lastShareId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (recentShares.length === 0) return;
|
||||
const top = recentShares[0];
|
||||
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`;
|
||||
if (key === lastShareId.current) return;
|
||||
lastShareId.current = key;
|
||||
|
||||
// On first load, we initialize the seen list to avoid back-filling old shares
|
||||
if (seenShares.current.size === 0) {
|
||||
for (const s of recentShares) {
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
seenShares.current.add(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newShares = [];
|
||||
for (let i = recentShares.length - 1; i >= 0; i--) {
|
||||
const s = recentShares[i];
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
if (!seenShares.current.has(key)) {
|
||||
seenShares.current.add(key);
|
||||
newShares.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenShares.current.size > 200) {
|
||||
const nextSet = new Set<string>();
|
||||
for (const s of recentShares) {
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
|
||||
nextSet.add(key);
|
||||
}
|
||||
seenShares.current = nextSet;
|
||||
}
|
||||
|
||||
for (const top of newShares) {
|
||||
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'share',
|
||||
@@ -212,15 +240,38 @@ export default function ActivityFeedPage() {
|
||||
detail: top.accepted ? undefined : top.error ?? 'pool rejection',
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
}
|
||||
}, [recentShares, push]);
|
||||
|
||||
// ── Fleet alert events ─────────────────────────────────────────────────
|
||||
const lastAlertId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (fleetAlerts.length === 0) return;
|
||||
const top = fleetAlerts[0];
|
||||
if (top.id === lastAlertId.current) return;
|
||||
lastAlertId.current = top.id;
|
||||
|
||||
if (seenAlerts.current.size === 0) {
|
||||
for (const a of fleetAlerts) {
|
||||
seenAlerts.current.add(a.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newAlerts = [];
|
||||
for (let i = fleetAlerts.length - 1; i >= 0; i--) {
|
||||
const a = fleetAlerts[i];
|
||||
if (!seenAlerts.current.has(a.id)) {
|
||||
seenAlerts.current.add(a.id);
|
||||
newAlerts.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenAlerts.current.size > 200) {
|
||||
const nextSet = new Set<string>();
|
||||
for (const a of fleetAlerts) {
|
||||
nextSet.add(a.id);
|
||||
}
|
||||
seenAlerts.current = nextSet;
|
||||
}
|
||||
|
||||
for (const top of newAlerts) {
|
||||
push({
|
||||
id: eid(), kind: 'alert',
|
||||
agentId: top.agent_id, agentName: top.agent_name,
|
||||
@@ -228,15 +279,32 @@ export default function ActivityFeedPage() {
|
||||
detail: top.type,
|
||||
ts: new Date(top.timestamp ?? Date.now()),
|
||||
});
|
||||
}
|
||||
}, [fleetAlerts, push]);
|
||||
|
||||
// ── Command result events ──────────────────────────────────────────────
|
||||
const lastCmdSeq = useRef(-1);
|
||||
useEffect(() => {
|
||||
if (commandResults.length === 0) return;
|
||||
const top = commandResults[commandResults.length - 1];
|
||||
if ((top._seq ?? -1) <= lastCmdSeq.current) return;
|
||||
lastCmdSeq.current = top._seq ?? -1;
|
||||
|
||||
if (lastCmdSeq.current === -1) {
|
||||
lastCmdSeq.current = Math.max(...commandResults.map((r) => r._seq ?? -1));
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = [];
|
||||
for (const r of commandResults) {
|
||||
const seq = r._seq ?? -1;
|
||||
if (seq > lastCmdSeq.current) {
|
||||
newResults.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (newResults.length > 0) {
|
||||
lastCmdSeq.current = Math.max(...newResults.map((r) => r._seq ?? -1));
|
||||
}
|
||||
|
||||
for (const top of newResults) {
|
||||
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
|
||||
push({
|
||||
id: eid(), kind: 'command',
|
||||
@@ -245,11 +313,24 @@ export default function ActivityFeedPage() {
|
||||
detail: top.success ? undefined : top.message?.slice(0, 80),
|
||||
ts: new Date(),
|
||||
});
|
||||
}
|
||||
}, [commandResults, push]);
|
||||
|
||||
// ── AI activity events ─────────────────────────────────────────────────
|
||||
const lastAiAgent = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (aiActivity.length === 0) return;
|
||||
|
||||
if (!aiInitialized.current) {
|
||||
for (const entry of aiActivity) {
|
||||
if (entry.last_action) {
|
||||
lastAiAgent.current[entry.agent_id] = entry.last_action;
|
||||
}
|
||||
}
|
||||
aiInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of aiActivity) {
|
||||
const lastAction = lastAiAgent.current[entry.agent_id];
|
||||
if (entry.last_action && entry.last_action !== lastAction) {
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
||||
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
|
||||
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||
@@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
|
||||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||||
}
|
||||
|
||||
// Poll interval (ms) for real server-side build progress.
|
||||
const FORGE_POLL_MS = 1000;
|
||||
|
||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
||||
if (!building) return null;
|
||||
return (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="forge-progress-track">
|
||||
<div
|
||||
className="forge-progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
|
||||
|
||||
function loadSimpleMode(): boolean {
|
||||
@@ -139,8 +118,7 @@ function loadSimpleMode(): boolean {
|
||||
export default function BuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
|
||||
const [form, setForm] = useState<BuildRequest | null>(null);
|
||||
const [building, setBuilding] = useState(false);
|
||||
@@ -217,52 +195,7 @@ export default function BuilderPage() {
|
||||
|
||||
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
||||
|
||||
// Poll real server-side build progress while a single build is running.
|
||||
// The server exposes GET /api/v1/builder/progress/{token} which returns
|
||||
// {stage, pct} updated at each key compile stage, so the bar reflects
|
||||
// actual server activity instead of a client-side time estimate.
|
||||
useEffect(() => {
|
||||
if (!building || batchJob) {
|
||||
endForge();
|
||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||
return;
|
||||
}
|
||||
startForge();
|
||||
|
||||
const token = cancelTokenRef.current;
|
||||
if (!token) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const { authHeaders } = await import('../api/auth');
|
||||
const res = await fetch(`/api/v1/builder/progress/${token}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { stage: string; pct: number } = await res.json();
|
||||
if (active && data.stage) {
|
||||
setStage(data.stage, data.pct);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network hiccup — keep polling
|
||||
}
|
||||
if (active) {
|
||||
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [building, batchJob]);
|
||||
useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef);
|
||||
|
||||
const setForgeMode = (simple: boolean) => {
|
||||
setSimpleMode(simple);
|
||||
@@ -348,7 +281,7 @@ export default function BuilderPage() {
|
||||
}, [searchParams, recentBuilds, form]);
|
||||
|
||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||
setStage('Build complete!', 100);
|
||||
setStage('Forged!', 100);
|
||||
setLastBuild(result);
|
||||
setDispenseReveal(result);
|
||||
loadRecentBuilds();
|
||||
@@ -789,7 +722,6 @@ export default function BuilderPage() {
|
||||
cancelToken,
|
||||
onStep: (step) => {
|
||||
setMissionStep(step);
|
||||
if (step === 'forge') startForge();
|
||||
},
|
||||
});
|
||||
setMissionExportSkipped(result.exportSkipped);
|
||||
|
||||
@@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
||||
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
|
||||
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
|
||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
|
||||
@@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
|
||||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||||
}
|
||||
|
||||
const FORGE_POLL_MS = 1000;
|
||||
|
||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
||||
if (!building) return null;
|
||||
return (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="forge-progress-track">
|
||||
<div className="forge-progress-fill" style={{ width: `${progress}%` }} />
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
|
||||
if (chip === 'ghost') return 'cyan';
|
||||
if (chip === 'loud') return 'magenta';
|
||||
@@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' |
|
||||
|
||||
export default function MissionDeckPage() {
|
||||
|
||||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
|
||||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
|
||||
const cancelTokenRef = useRef('');
|
||||
|
||||
@@ -166,52 +147,10 @@ export default function MissionDeckPage() {
|
||||
.catch(() => setError('Failed to load server config — is the control server running?'))
|
||||
.finally(() => setLoadingDefaults(false));
|
||||
}, []);
|
||||
// Poll real server-side build progress (same as BuilderPage).
|
||||
useEffect(() => {
|
||||
if (!building) {
|
||||
endForge();
|
||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||
return;
|
||||
}
|
||||
startForge();
|
||||
useForgeProgressPoll(building, cancelTokenRef);
|
||||
|
||||
const token = cancelTokenRef.current;
|
||||
if (!token) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const { authHeaders } = await import('../api/auth');
|
||||
const res = await fetch(`/api/v1/builder/progress/${token}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { stage: string; pct: number } = await res.json();
|
||||
if (active && data.stage) {
|
||||
setStage(data.stage, data.pct);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network hiccup — keep polling
|
||||
}
|
||||
if (active) {
|
||||
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
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(() => {});
|
||||
};
|
||||
@@ -247,7 +186,7 @@ export default function MissionDeckPage() {
|
||||
};
|
||||
|
||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||
setStage('Build complete!', 100);
|
||||
setStage('Forged!', 100);
|
||||
setDispenseReveal(result);
|
||||
};
|
||||
|
||||
@@ -301,7 +240,6 @@ export default function MissionDeckPage() {
|
||||
cancelToken,
|
||||
onStep: (step) => {
|
||||
setMissionStep(step);
|
||||
if (step === 'forge') startForge();
|
||||
},
|
||||
});
|
||||
setMissionExportSkipped(result.exportSkipped);
|
||||
|
||||
@@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint {
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
|
||||
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.forge-progress-track.indeterminate {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forge-progress-track.indeterminate .forge-progress-fill {
|
||||
width: 35%;
|
||||
animation: forge-progress-indeterminate 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes forge-progress-indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
|
||||
.forge-progress-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ROIPage() {
|
||||
? (hr / totalHashrate) * xmrPerDay
|
||||
: 0;
|
||||
const nodeUsdDay = nodeXmrDay * price;
|
||||
const nodeCores = a.cpu_cores ?? 0;
|
||||
const nodeCores = a.status === 'online' ? (a.cpu_cores ?? 0) : 0;
|
||||
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
|
||||
const nodeKwhDay = (nodeWatts / 1000) * 24;
|
||||
const nodeElecCost = nodeKwhDay * kwh;
|
||||
|
||||
@@ -580,8 +580,8 @@ export default function SettingsPage() {
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Deck Atmosphere</h2>
|
||||
<p className="section-desc">
|
||||
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
|
||||
low-power devices if you want a calmer deck.
|
||||
Rising 0/1 and hex glyphs follow your pointer on desktop deck pages. Background glow
|
||||
particles sit behind the UI. Turn off on low-power devices for a calmer deck.
|
||||
</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
@@ -591,7 +591,7 @@ export default function SettingsPage() {
|
||||
checked={glowParticles}
|
||||
onChange={(e) => setGlowParticles(e.target.checked)}
|
||||
/>
|
||||
<span>Glow particles & sparkles</span>
|
||||
<span>Hacker cursor trail</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: '1.25rem' }}>
|
||||
|
||||
@@ -45,7 +45,7 @@ body {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.cursor-fire-fx {
|
||||
.cursor-hacker-fx {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('visualPrefs', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults glow particles to on', () => {
|
||||
it('defaults hacker cursor trail to on', () => {
|
||||
expect(loadGlowParticlesEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
174
usb/LAUNCH.bat
174
usb/LAUNCH.bat
@@ -2,23 +2,52 @@
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title AetherForge Control Deck
|
||||
cd /d "%~dp0"
|
||||
set "REPO=%CD%"
|
||||
|
||||
echo.
|
||||
echo ================================================================
|
||||
echo AetherForge - One-Click Launch
|
||||
echo ================================================================
|
||||
echo Folder: %REPO%
|
||||
echo.
|
||||
|
||||
set "PREP=%REPO%\scripts\launch-prep.bat"
|
||||
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
|
||||
if exist "%PREP%" (
|
||||
call "%PREP%" "%REPO%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo LAUNCH prep failed - fix errors above and retry.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
|
||||
)
|
||||
|
||||
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
|
||||
echo.
|
||||
echo [Dry run] Prep steps OK - not starting tunnel or server.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
|
||||
set "ROOT="
|
||||
if exist "%CD%\AetherForge.exe" (
|
||||
set "ROOT=!CD!"
|
||||
) else if exist "%CD%\usb\AetherForge.exe" (
|
||||
cd /d "%CD%\usb"
|
||||
set "ROOT=!CD!"
|
||||
) else (
|
||||
echo.
|
||||
echo ERROR: AetherForge.exe not found.
|
||||
echo Expected next to this script, or in usb\AetherForge.exe
|
||||
echo Run pack-usb.bat from the repo to build the portable bundle.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
|
||||
|
||||
:: No portable binary - dev control server from repo
|
||||
goto dev_server_launch
|
||||
|
||||
:portable_deck
|
||||
|
||||
if not exist "%ROOT%\AetherForge.exe" (
|
||||
echo ERROR: AetherForge.exe missing in %ROOT%
|
||||
pause
|
||||
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
:: Check if Go is installed system-wide
|
||||
where go >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
echo [Go] Using system Go installation.
|
||||
@@ -58,7 +86,6 @@ if not errorlevel 1 (
|
||||
goto go_ready
|
||||
)
|
||||
|
||||
:: Go not found anywhere - skip optional tools, proceed directly to server
|
||||
echo.
|
||||
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
|
||||
echo.
|
||||
@@ -66,17 +93,11 @@ goto server_launch
|
||||
|
||||
:go_ready
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 2. Pin all Go caches to the USB so module downloads travel with you
|
||||
:: ----------------------------------------------------------------
|
||||
set "GOPATH=%ROOT%\toolchain\gopath"
|
||||
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
|
||||
set "GOCACHE=%ROOT%\toolchain\gocache"
|
||||
set "GOENV=off"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 3. Install optional Forge tools if missing (non-fatal)
|
||||
:: ----------------------------------------------------------------
|
||||
if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
|
||||
echo [Tools] Installing garble...
|
||||
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
|
||||
|
||||
:server_launch
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 4. Ensure data directories exist
|
||||
:: ----------------------------------------------------------------
|
||||
echo.
|
||||
echo Ensuring data directories...
|
||||
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
|
||||
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
|
||||
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
|
||||
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
|
||||
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
|
||||
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 5. Detect LAN IP for display
|
||||
:: ----------------------------------------------------------------
|
||||
set "SERVER_PORT=8989"
|
||||
set "CONFIG_FILE=%ROOT%\data\config.json"
|
||||
if exist "%CONFIG_FILE%" (
|
||||
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
|
||||
:lan_done
|
||||
set "LAN_IP=%LAN_IP: =%"
|
||||
|
||||
:: ----------------------------------------------------------------
|
||||
:: 6. Kill any stale server and tunnel processes
|
||||
:: ----------------------------------------------------------------
|
||||
echo Stopping stale processes...
|
||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
|
||||
echo Data: %ROOT%\data\
|
||||
echo.
|
||||
echo Login accounts: admin + comrade ^(passwords below after start^).
|
||||
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
|
||||
echo Press Ctrl+C to stop.
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
|
||||
echo Starting tunnel...
|
||||
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
|
||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
|
||||
if exist "%CF_SCRIPT%" (
|
||||
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
|
||||
cd /d "%ROOT%"
|
||||
|
||||
:: Open browser after short delay
|
||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||
|
||||
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
|
||||
echo Starting server...
|
||||
set "AF_TUNNEL_EXTERNAL=1"
|
||||
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
|
||||
set "EC=!ERRORLEVEL!"
|
||||
|
||||
if exist "%ROOT%\data\cloudflared.pid" (
|
||||
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
|
||||
call :cleanup_tunnel "%ROOT%"
|
||||
goto server_stopped
|
||||
|
||||
:dev_server_launch
|
||||
echo.
|
||||
echo No AetherForge.exe - starting dev control server ^(repo^).
|
||||
echo ^(Run pack-usb.bat for portable USB bundle.^)
|
||||
echo.
|
||||
|
||||
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
|
||||
set "DATA=%REPO%\data"
|
||||
set "DECK=%REPO%"
|
||||
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
|
||||
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
|
||||
|
||||
echo Ensuring data directories...
|
||||
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
|
||||
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
|
||||
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
|
||||
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
|
||||
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
|
||||
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
|
||||
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
|
||||
|
||||
set "SERVER_PORT=8989"
|
||||
set "CONFIG_FILE=%DATA%\config.json"
|
||||
if exist "%CONFIG_FILE%" (
|
||||
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
|
||||
if not "%%P"=="" set "SERVER_PORT=%%P"
|
||||
)
|
||||
)
|
||||
set "LAN_IP=localhost"
|
||||
|
||||
echo Stopping stale processes...
|
||||
taskkill /F /IM miner-server.exe >nul 2>nul
|
||||
taskkill /F /IM AetherForge.exe >nul 2>nul
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
|
||||
where go >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%REPO%\bin\miner-server.exe" (
|
||||
echo Building control server...
|
||||
cd /d "%REPO%\server"
|
||||
go mod download >nul 2>nul
|
||||
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
|
||||
if errorlevel 1 (
|
||||
cd /d "%REPO%"
|
||||
echo ERROR: Server build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%REPO%"
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================================
|
||||
echo STARTING CONTROL SERVER ^(dev^)
|
||||
echo ================================================================
|
||||
echo Dashboard: http://localhost:%SERVER_PORT%
|
||||
echo Data: %DATA%\
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
echo Starting tunnel...
|
||||
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
|
||||
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
|
||||
if exist "%CF_SCRIPT%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
|
||||
) else (
|
||||
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
|
||||
)
|
||||
echo.
|
||||
|
||||
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
|
||||
|
||||
echo Starting server...
|
||||
cd /d "%REPO%"
|
||||
set "AF_TUNNEL_EXTERNAL=1"
|
||||
"%REPO%\bin\miner-server.exe" -data "%DATA%"
|
||||
set "EC=!ERRORLEVEL!"
|
||||
|
||||
call :cleanup_tunnel "%DECK%"
|
||||
goto server_stopped
|
||||
|
||||
:cleanup_tunnel
|
||||
set "TROOT=%~1"
|
||||
if exist "%TROOT%\data\cloudflared.pid" (
|
||||
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
|
||||
taskkill /F /PID %%P >nul 2>nul
|
||||
)
|
||||
del "%ROOT%\data\cloudflared.pid" 2>nul
|
||||
del "%TROOT%\data\cloudflared.pid" 2>nul
|
||||
)
|
||||
taskkill /F /IM cloudflared.exe >nul 2>nul
|
||||
exit /b 0
|
||||
|
||||
:server_stopped
|
||||
echo.
|
||||
if "!EC!"=="0" (
|
||||
echo [Server] Stopped normally.
|
||||
) else (
|
||||
echo [Server] Exited with code !EC!.
|
||||
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
|
||||
echo If port %SERVER_PORT% is in use, close other server windows and retry.
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
Reference in New Issue
Block a user