9 Commits

Author SHA1 Message Date
AetherForge
689e574f7a Update server config, builder APK logic, frontend fleet/activity metrics, and ignore test APKs
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-13 19:54:24 -07:00
AetherForge
df88d160cb Refresh portable USB bundle with freshly built server binary and synced LAUNCH.bat.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-09 04:17:28 -07:00
AetherForge
615034554c One-click LAUNCH.bat: pull, UI build, tunnel, and server start.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators double-click LAUNCH for git pull, npm build, data prep, cloudflared, and AetherForge or dev miner-server; devrun shares launch-prep.
2026-06-09 04:03:02 -07:00
AetherForge
9a884a80b6 Make hacker cursor trails visible on all desktop deck pages.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Remove Command-Deck-only gating, boost 0/1 particle intensity, add crosshair cursor, and relabel the settings toggle so operators can find the effect.
2026-06-08 19:53:06 -07:00
AetherForge
6ab43a468b Forge success label and real progress bar tied to server compile stages.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Replace Dispensed with Forged in the reveal modal, poll builder/progress with indeterminate-until-first-byte UX, and emit interpolated compile progress during long garble builds.
2026-06-08 19:48:46 -07:00
AetherForge
0c7b676e23 Replace cursor fire with hacker bit-trail particles on Command Deck.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Swaps orange flame blobs for rising 0/1 and hex glyphs in green/cyan with glow, capped rAF particles, and prefers-reduced-motion support.
2026-06-08 19:40:41 -07:00
AetherForge
ebcb1e1210 fix: mine-validate throughput timing for short runs
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-08 17:00:05 -07:00
AetherForge
d4ac5d435a fix: mine-validate throughput timing for short runs
Seed the pool job before workers start and sample hashrate every second so sub-5s validations reflect real hashing.
2026-06-08 16:58:46 -07:00
1d36d21242 Add MIT License
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-08 16:34:16 -07:00
43 changed files with 1348 additions and 426 deletions

2
.gitignore vendored
View File

@@ -82,7 +82,7 @@ _*.txt
/server/*cov* /server/*cov*
# Local APK / test logs (not tracked) # Local APK / test logs (not tracked)
/agent-tablet-1.apk /agent-*.apk
/server/web/test-output.txt /server/web/test-output.txt
# Go build cache (local) # Go build cache (local)

View File

@@ -2,23 +2,52 @@
setlocal EnableExtensions EnableDelayedExpansion setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck title AetherForge Control Deck
cd /d "%~dp0" 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) :: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
set "ROOT="
if exist "%CD%\AetherForge.exe" ( if exist "%CD%\AetherForge.exe" (
set "ROOT=!CD!" set "ROOT=!CD!"
) else if exist "%CD%\usb\AetherForge.exe" ( ) else if exist "%CD%\usb\AetherForge.exe" (
cd /d "%CD%\usb" cd /d "%CD%\usb"
set "ROOT=!CD!" 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" ( if not exist "%ROOT%\AetherForge.exe" (
echo ERROR: AetherForge.exe missing in %ROOT% echo ERROR: AetherForge.exe missing in %ROOT%
pause pause
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
goto go_ready goto go_ready
) )
:: Check if Go is installed system-wide
where go >nul 2>nul where go >nul 2>nul
if not errorlevel 1 ( if not errorlevel 1 (
echo [Go] Using system Go installation. echo [Go] Using system Go installation.
@@ -58,7 +86,6 @@ if not errorlevel 1 (
goto go_ready goto go_ready
) )
:: Go not found anywhere - skip optional tools, proceed directly to server
echo. echo.
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them. echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
echo. echo.
@@ -66,17 +93,11 @@ goto server_launch
:go_ready :go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath" set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod" set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache" set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off" set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i not "%AF_INSTALL_TOOLS%"=="0" ( if /i not "%AF_INSTALL_TOOLS%"=="0" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" ( if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble... echo [Tools] Installing garble...
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
:server_launch :server_launch
:: ---------------------------------------------------------------- echo.
:: 4. Ensure data directories exist echo Ensuring data directories...
:: ----------------------------------------------------------------
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds" 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\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits" 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\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json" set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" ( if exist "%CONFIG_FILE%" (
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
:lan_done :lan_done
set "LAN_IP=%LAN_IP: =%" set "LAN_IP=%LAN_IP: =%"
:: ---------------------------------------------------------------- echo Stopping stale processes...
:: 6. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >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 Data: %ROOT%\data\
echo. echo.
echo Login accounts: admin + comrade ^(passwords below after start^). echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop. echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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" set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" 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%" ( if exist "%CF_SCRIPT%" (
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
) )
echo. echo.
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
cd /d "%ROOT%" cd /d "%ROOT%"
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data" "%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" ( call :cleanup_tunnel "%ROOT%"
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do ( 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 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 taskkill /F /IM cloudflared.exe >nul 2>nul
exit /b 0
:server_stopped
echo. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.
) else ( ) else (
echo [Server] Exited with code !EC!. 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. echo.

21
LICENSE Normal file
View 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.

View File

@@ -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. **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). 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.
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.
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) ### Operator path (dev control PC)
1. Double-click **`devrun.bat`** in the project root. 1. **`LAUNCH.bat`** — same one-click path as above (preferred).
Installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets, and starts the server. 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. **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
9. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
### Portable USB command deck ### Portable USB command deck
1. Run **`pack-usb.bat`** from repo root (re-run after any code change) 1. Run **`pack-usb.bat`** from repo root (re-run after any code change)

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"encoding/json" "encoding/json"
"os/exec"
"testing" "testing"
"time" "time"
@@ -15,6 +16,24 @@ import (
// real engine so handleMessage can call pool.SetJob without panicking. // real engine so handleMessage can call pool.SetJob without panicking.
func newTestClient(t *testing.T) *AgentClient { func newTestClient(t *testing.T) *AgentClient {
t.Helper() 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 := config.GetBuiltinConfig()
b.Threads = 1 b.Threads = 1
cfg := config.RuntimeConfig{BuiltinConfig: b} cfg := config.RuntimeConfig{BuiltinConfig: b}

View File

@@ -169,7 +169,6 @@ func main() {
sharesFound++ sharesFound++
fmt.Printf(" ★ SHARE job=%-14s nonce=%s\n", jobID, nonce) fmt.Printf(" ★ SHARE job=%-14s nonce=%s\n", jobID, nonce)
}) })
pool.Start()
pool.SetJob(&job.Job{ pool.SetJob(&job.Job{
ID: "validate-001", ID: "validate-001",
Blob: testBlobHex, Blob: testBlobHex,
@@ -177,9 +176,11 @@ func main() {
SeedHash: testSeedHex, SeedHash: testSeedHex,
Height: 3000000, Height: 3000000,
}) })
pool.Start()
fmt.Printf(" ✓ %d worker(s) started, job injected\n", *threads) 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) done := time.After(time.Duration(*seconds) * time.Second)
elapsed := 0 elapsed := 0
var finalHS float64 var finalHS float64
@@ -187,20 +188,23 @@ loop:
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
elapsed += 5 elapsed += 1
hs := pool.HashesPerSecond() hs := pool.HashesPerSecond()
pool.ResetHashCounter() pool.ResetHashCounter()
finalHS = hs finalHS = hs
fmt.Printf(" [%2ds] %8.1f H/s shares=%d\n", elapsed, hs, sharesFound) fmt.Printf(" [%2ds] %8.1f H/s shares=%d\n", elapsed, hs, sharesFound)
case <-done: case <-done:
ticker.Stop() ticker.Stop()
if hs := pool.HashesPerSecond(); hs > finalHS {
finalHS = hs
}
break loop break loop
} }
} }
pool.Stop() pool.Stop()
if finalHS < 1 { if finalHS < 1 && sharesFound == 0 {
fail(fmt.Sprintf("hashrate is 0 after %ds", *seconds), &ok) fail(fmt.Sprintf("hashrate is 0 and no shares after %ds", *seconds), &ok)
} else { } else {
fmt.Printf("\n ✓ %.0f H/s total / %.0f H/s per thread\n", finalHS, finalHS/float64(*threads)) fmt.Printf("\n ✓ %.0f H/s total / %.0f H/s per thread\n", finalHS, finalHS/float64(*threads))
if sharesFound > 0 { if sharesFound > 0 {

View File

@@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
default: default:
} }
start := time.Now() start := time.Now()
err := o.invokeTier(tier, hooks) err := o.invokeTier(ctx, tier, hooks)
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
if errors.Is(err, ErrTierChainSkipped) { if errors.Is(err, ErrTierChainSkipped) {
@@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
return "", ErrTierChainExhausted 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 { switch tier {
case TierDockerLoad: case TierDockerLoad:
if hooks.StartDockerLoad == nil { if hooks.StartDockerLoad == nil {
@@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return hooks.StartDotnet() return hooks.StartDotnet()
case TierWMI: case TierWMI:
attempt := RunWMITier(context.Background(), o.cfg) attempt := RunWMITier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if !attempt.OK { if !attempt.OK {
if attempt.Error == "" { if attempt.Error == "" {
@@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return nil return nil
case TierScheduledTask: case TierScheduledTask:
attempt := RunScheduledTaskTier(context.Background(), o.cfg) attempt := RunScheduledTaskTier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if !attempt.OK { if !attempt.OK {
if attempt.Error == "" { if attempt.Error == "" {
@@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
} }
return nil return nil
case TierGPUCompute: case TierGPUCompute:
attempt := RunGPUComputeTier(context.Background(), o.cfg) attempt := RunGPUComputeTier(ctx, o.cfg)
o.recordAttemptRecord(attempt) o.recordAttemptRecord(attempt)
if attempt.OK { if attempt.OK {
o.mu.Lock() o.mu.Lock()

View File

@@ -1,9 +1,9 @@
{ {
"server_url": "http://deck:8989", "server_url": "http://10.0.0.1:8989",
"worker_name": "tab-1", "worker_name": "cleanup-node",
"worker_number": "tab-1", "worker_number": "cleanup-node",
"mining": { "mining": {
"enabled": false "enabled": false
}, },
"build_id": "bld-cross" "build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0"
} }

View File

@@ -73,7 +73,7 @@ echo Go installed.
:go_ready :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)... echo [1.5/5] Checking Forge tools (Garble, go-winres)...
where garble >nul 2>nul where garble >nul 2>nul
if errorlevel 1 ( if errorlevel 1 (
@@ -131,6 +131,26 @@ echo Node.js installed.
:node_ready :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 :: STEP 3: Data directories
:: ============================================================ :: ============================================================
@@ -142,44 +162,6 @@ if not exist "data\preps" mkdir "data\preps"
if not exist "bin" mkdir "bin" if not exist "bin" mkdir "bin"
echo OK: data\ and 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 :: STEP 5: Server binary
@@ -198,7 +180,7 @@ if errorlevel 1 (
cd /d "%ROOT%" cd /d "%ROOT%"
if defined AETHERFORGE_RELEASE ( 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" ( 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 echo Server binary: bin\miner-server.exe
:: ============================================================ :: ============================================================
:: LAUNCH (foreground logs stay in this window) :: LAUNCH (foreground ??? logs stay in this window)
:: ============================================================ :: ============================================================
echo. echo.
echo Stopping any previous miner-server.exe... echo Stopping any previous miner-server.exe...
@@ -261,7 +243,7 @@ goto end_pause
:fatal_exit :fatal_exit
echo. echo.
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 ==============================================================
echo. echo.

View File

@@ -150,6 +150,7 @@ echo [5/8] Launcher synced.
if not exist "%USB%\scripts" mkdir "%USB%\scripts" if not exist "%USB%\scripts" mkdir "%USB%\scripts"
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul 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" ( if not exist "%USB%\data\cloudflared-token.txt" (
echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt" echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
) )

74
scripts/launch-prep.bat Normal file
View 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

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
@@ -345,8 +345,6 @@ func LoadConfig() *Config {
cfg.DataDir = resolveDataDir(*dataDir, projectRoot) cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit { if cliPortExplicit {
cfg.Port = cliPort cfg.Port = cliPort
} else {
cfg.Port = cliPort
} }
configPath := filepath.Join(cfg.DataDir, "config.json") configPath := filepath.Join(cfg.DataDir, "config.json")

View File

@@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
if err := json.Unmarshal([]byte(payload), &present); err != nil { if err := json.Unmarshal([]byte(payload), &present); err != nil {
t.Fatalf("unmarshal present keys: %v", err) t.Fatalf("unmarshal present keys: %v", err)
} }
hydrateLegacyAIConfig(&incoming, []byte(payload))
mergeConfigExplicit(dst, &incoming, present) mergeConfigExplicit(dst, &incoming, present)
} }
@@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
t.Fatal("test precondition") 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)
}
}

View File

@@ -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. // 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) { func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
h.apkBuildMu.Lock()
defer h.apkBuildMu.Unlock()
if req.ScoutMode { if req.ScoutMode {
ApplyApkScoutPreset(req) ApplyApkScoutPreset(req)
} else { } else {
@@ -253,8 +256,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
} }
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""} 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) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" 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) h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(buildRecord); err != nil { if err := h.db.InsertBuild(buildRecord); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
} }

View File

@@ -3,10 +3,13 @@ package builder
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time"
) )
func TestApplyApkScoutPreset(t *testing.T) { 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) 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)
}
}

View File

@@ -34,10 +34,17 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
workerPaths := map[string]string{} workerPaths := map[string]string{}
total := len(platforms) total := len(platforms)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
for i, p := range platforms { for i, p := range platforms {
pct := 14 + (i*56)/total startPct := 14 + (i*56)/total
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct) 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) wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""

View File

@@ -232,8 +232,12 @@ type Handler struct {
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script). // apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
apkBuildFn ApkBuildFunc 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. // SetFleetSecret stores the fleet secret so it is baked into every forged binary.
func (h *Handler) SetFleetSecret(secret string) { func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret h.fleetSecret = secret
@@ -690,15 +694,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
p := platforms[0] 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) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
log.Printf("Build failed: %v", err) log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Compiled — linking output", 72) h.setProgress(req.CancelToken, "Compiled — linking output", 72)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
workerName := filepath.Base(outputPath) workerName := filepath.Base(outputPath)
finalPath := outputPath finalPath := outputPath
finalName := workerName finalName := workerName

View 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) }
}

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"context" "context"
@@ -68,6 +68,9 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("AetherForge C2 starting...") log.Println("AetherForge C2 starting...")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
projectRoot := findProjectRoot() projectRoot := findProjectRoot()
cfg := LoadConfig() cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
@@ -278,8 +281,13 @@ func main() {
go func() { go func() {
ticker := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
select {
case <-ticker.C:
wsHub.BroadcastPoolStatus(poolManager.ListStatus()) wsHub.BroadcastPoolStatus(poolManager.ListStatus())
case <-ctx.Done():
return
}
} }
}() }()
@@ -398,10 +406,13 @@ func main() {
// Start server // Start server
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: router} srv := &http.Server{
Addr: addr,
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) Handler: router,
defer stop() ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("Server listening on %s", addr) log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port) 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") 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 var presentKeys map[string]json.RawMessage
_ = json.Unmarshal(data, &presentKeys) _ = json.Unmarshal(data, &presentKeys)
hydrateLegacyAIConfig(&incoming, data)
mergeConfigExplicit(p.config, &incoming, presentKeys) mergeConfigExplicit(p.config, &incoming, presentKeys)
// Save to disk // Save to disk

View File

@@ -118,6 +118,10 @@ export default function CrucibleExpandedOps({
} }
}, [singleSelectedAgent?.mac_address, wolMac]); }, [singleSelectedAgent?.mac_address, wolMac]);
useEffect(() => {
setLiveDesktop(false);
}, [singleSelectedAgent?.id]);
const dispatchOne = useCallback( const dispatchOne = useCallback(
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => { async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
try { try {

View 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();
});
});

View File

@@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {
<div className="forge-dispense-panel"> <div className="forge-dispense-panel">
<div className="forge-dispense-sigil" aria-hidden /> <div className="forge-dispense-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title"> <h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed Forged
</h2> </h2>
<p className="forge-dispense-sub"> <p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature. {result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.

View 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();
});
});

View 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>
);
}

View File

@@ -348,10 +348,10 @@ export default function Layout({ children }: LayoutProps) {
return ( return (
<div <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)} data-operator-deck={operatorDeckId(location.pathname)}
> >
{!isMobile && glowParticles && showDeckEffects && <CursorFire />} {!isMobile && glowParticles && <CursorFire />}
<AmbientBackground weather={pageWeather} /> <AmbientBackground weather={pageWeather} />
{glowParticles && <SacredGeometryLayer />} {glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only"> <nav className="sidebar sidebar--desktop desktop-only">

View 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;
}
}

View File

@@ -1,114 +1,143 @@
import { useEffect, useRef } from 'react'; 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 { interface Particle {
x: number; x: number;
y: number; y: number;
vx: number; vx: number;
vy: number; vy: number;
life: number; // 1 → 0 life: number;
size: number;
decay: 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() { export default function CursorFire() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const particles = useRef<Particle[]>([]); const particles = useRef<Particle[]>([]);
const mouse = useRef({ x: -9999, y: -9999, moved: false }); const mouse = useRef({ x: -9999, y: -9999 });
const rafRef = useRef<number>(0); const lastMoveRef = useRef(0);
const rafRef = useRef(0);
useEffect(() => { useEffect(() => {
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
if (motionQuery.matches) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
let running = true;
const resize = () => { const resize = () => {
canvas.width = window.innerWidth; const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.height = window.innerHeight; 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(); resize();
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
const onMove = (e: MouseEvent) => { 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 = () => { const emit = () => {
if (performance.now() - lastMoveRef.current > EMIT_WINDOW_MS) return;
const { x, y } = mouse.current; const { x, y } = mouse.current;
// Emit 6 particles per frame at cursor
for (let i = 0; i < 6; i++) { for (let i = 0; i < EMIT_PER_FRAME; i++) {
const spread = 8; const spread = 14;
particles.current.push({ particles.current.push({
x: x + (Math.random() - 0.5) * spread, x: x + (Math.random() - 0.5) * spread,
y: y + (Math.random() - 0.5) * (spread * 0.5), y: y + (Math.random() - 0.5) * (spread * 0.45),
vx: (Math.random() - 0.5) * 1.2, vx: (Math.random() - 0.5) * 1.8,
vy: -(Math.random() * 2.8 + 1.8), vy: -(Math.random() * 3.2 + 2.1),
life: 1, life: 1,
size: Math.random() * 14 + 7, decay: Math.random() * 0.016 + 0.012,
decay: Math.random() * 0.022 + 0.016, char: pickChar(),
fontSize: Math.random() * 16 + 16,
tint: Math.random() < 0.5 ? 'cyan' : 'green',
}); });
} }
// Cap particle count for perf
if (particles.current.length > 400) { if (particles.current.length > MAX_PARTICLES) {
particles.current = particles.current.slice(-400); particles.current = particles.current.slice(-MAX_PARTICLES);
} }
}; };
const draw = () => { const draw = () => {
if (!running) return;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
// Additive blending makes overlapping particles look white-hot
ctx.globalCompositeOperation = 'screen';
emit(); emit();
const alive: Particle[] = []; const alive: Particle[] = [];
for (const p of particles.current) { for (const p of particles.current) {
// Turbulent horizontal drift p.vx += (Math.random() - 0.5) * 0.28;
p.vx += (Math.random() - 0.5) * 0.35; p.vx *= 0.96;
// Slight drag on vx p.vy -= 0.035;
p.vx *= 0.97;
// Upward acceleration (heat rises)
p.vy -= 0.04;
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
p.life -= p.decay; p.life -= p.decay;
// Particles shrink as they cool p.fontSize *= 0.985;
p.size *= 0.982;
if (p.life <= 0 || p.size < 1) continue; if (p.life <= 0 || p.fontSize < 8) continue;
alive.push(p); alive.push(p);
const l = p.life; const glow = 10 + p.life * 18;
// Color temperature: white-yellow core → orange → red → dark red ctx.shadowBlur = glow;
let r: number, g: number, b: number; ctx.shadowColor = glowForTint(p.tint);
if (l > 0.75) { ctx.font = `600 ${p.fontSize}px ${FONT_STACK}`;
// White-hot ctx.textAlign = 'center';
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220); ctx.textBaseline = 'middle';
} else if (l > 0.5) { ctx.fillStyle = colorForLife(p.life, p.tint);
// Yellow-orange ctx.fillText(p.char, p.x, p.y);
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();
} }
ctx.shadowBlur = 0;
particles.current = alive; particles.current = alive;
rafRef.current = requestAnimationFrame(draw); rafRef.current = requestAnimationFrame(draw);
}; };
@@ -116,22 +145,13 @@ export default function CursorFire() {
rafRef.current = requestAnimationFrame(draw); rafRef.current = requestAnimationFrame(draw);
return () => { return () => {
running = false;
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', resize); window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
motionQuery.removeEventListener('change', stopOnReducedMotion);
}; };
}, []); }, []);
return ( return <canvas ref={canvasRef} className="cursor-hacker-fx" aria-hidden="true" />;
<canvas
ref={canvasRef}
className="cursor-fire-fx"
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
}}
/>
);
} }

View File

@@ -907,9 +907,29 @@ describe('AmbientBackground', () => {
describe('CursorFire', () => { describe('CursorFire', () => {
afterEach(() => cleanup()); afterEach(() => cleanup());
it('mounts fullscreen canvas', () => { it('mounts fullscreen hacker-trail canvas', () => {
const { container } = render(<CursorFire />); 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(); 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( const { container: deck } = render(
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}> <MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout> <Layout>
@@ -980,7 +1000,7 @@ describe('Layout', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy(); expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy(); expect(deck.querySelector('.cursor-hacker-fx')).toBeTruthy();
}); });
cleanup(); cleanup();
@@ -995,6 +1015,7 @@ describe('Layout', () => {
expect(screen.getByText('crucible')).toBeInTheDocument(); expect(screen.getByText('crucible')).toBeInTheDocument();
}); });
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull(); 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();
}); });
}); });

View File

@@ -37,4 +37,21 @@ describe('fleetGroups', () => {
saveFleetGroups(groups); saveFleetGroups(groups);
expect(loadFleetGroups()).toHaveLength(2); 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);
});
}); });

View File

@@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] {
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))] ? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
: []; : [];
return { 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, name,
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]), color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
agentIds, agentIds,

View File

@@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => {
}), }),
).toBe(false); ).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', () => { describe('WS_LATEST_MESSAGE_TYPES', () => {

View File

@@ -1,4 +1,4 @@
import type { Agent } from '../types'; import type { Agent, AgentService } from '../types';
import type { WSStatsUpdate } from '../types/ws'; import type { WSStatsUpdate } from '../types/ws';
/** Returns true when a stats_update payload would not change visible agent fields. */ /** 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.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_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.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_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.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.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_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.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_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.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.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.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; 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; 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). */ /** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([ export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot', 'presence_snapshot',

View 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]);
}

View File

@@ -103,6 +103,9 @@ export default function ActivityFeedPage() {
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score 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 // Build agent name lookup
useEffect(() => { useEffect(() => {
@@ -160,7 +163,7 @@ export default function ActivityFeedPage() {
const prev = prevHashrates.current[agent.id]; const prev = prevHashrates.current[agent.id];
const cur = agent.hashrate_15m ?? 0; const cur = agent.hashrate_15m ?? 0;
prevHashrates.current[agent.id] = cur; prevHashrates.current[agent.id] = cur;
if (prev === undefined || prev <= 0) continue; if (prev === undefined) continue;
const delta = cur - prev; const delta = cur - prev;
// Only emit if ≥20% change AND at least 100 H/s delta // 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) { if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
@@ -197,13 +200,38 @@ export default function ActivityFeedPage() {
}, [agents, push]); }, [agents, push]);
// ── New share events ─────────────────────────────────────────────────── // ── New share events ───────────────────────────────────────────────────
const lastShareId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (recentShares.length === 0) return; if (recentShares.length === 0) return;
const top = recentShares[0];
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`; // On first load, we initialize the seen list to avoid back-filling old shares
if (key === lastShareId.current) return; if (seenShares.current.size === 0) {
lastShareId.current = key; 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); const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
push({ push({
id: eid(), kind: 'share', id: eid(), kind: 'share',
@@ -212,15 +240,38 @@ export default function ActivityFeedPage() {
detail: top.accepted ? undefined : top.error ?? 'pool rejection', detail: top.accepted ? undefined : top.error ?? 'pool rejection',
ts: new Date(top.timestamp ?? Date.now()), ts: new Date(top.timestamp ?? Date.now()),
}); });
}
}, [recentShares, push]); }, [recentShares, push]);
// ── Fleet alert events ───────────────────────────────────────────────── // ── Fleet alert events ─────────────────────────────────────────────────
const lastAlertId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (fleetAlerts.length === 0) return; if (fleetAlerts.length === 0) return;
const top = fleetAlerts[0];
if (top.id === lastAlertId.current) return; if (seenAlerts.current.size === 0) {
lastAlertId.current = top.id; 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({ push({
id: eid(), kind: 'alert', id: eid(), kind: 'alert',
agentId: top.agent_id, agentName: top.agent_name, agentId: top.agent_id, agentName: top.agent_name,
@@ -228,15 +279,32 @@ export default function ActivityFeedPage() {
detail: top.type, detail: top.type,
ts: new Date(top.timestamp ?? Date.now()), ts: new Date(top.timestamp ?? Date.now()),
}); });
}
}, [fleetAlerts, push]); }, [fleetAlerts, push]);
// ── Command result events ────────────────────────────────────────────── // ── Command result events ──────────────────────────────────────────────
const lastCmdSeq = useRef(-1); const lastCmdSeq = useRef(-1);
useEffect(() => { useEffect(() => {
if (commandResults.length === 0) return; if (commandResults.length === 0) return;
const top = commandResults[commandResults.length - 1];
if ((top._seq ?? -1) <= lastCmdSeq.current) return; if (lastCmdSeq.current === -1) {
lastCmdSeq.current = top._seq ?? -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); const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({ push({
id: eid(), kind: 'command', id: eid(), kind: 'command',
@@ -245,11 +313,24 @@ export default function ActivityFeedPage() {
detail: top.success ? undefined : top.message?.slice(0, 80), detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(), ts: new Date(),
}); });
}
}, [commandResults, push]); }, [commandResults, push]);
// ── AI activity events ───────────────────────────────────────────────── // ── AI activity events ─────────────────────────────────────────────────
const lastAiAgent = useRef<Record<string, string>>({}); const lastAiAgent = useRef<Record<string, string>>({});
useEffect(() => { 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) { for (const entry of aiActivity) {
const lastAction = lastAiAgent.current[entry.agent_id]; const lastAction = lastAiAgent.current[entry.agent_id];
if (entry.last_action && entry.last_action !== lastAction) { if (entry.last_action && entry.last_action !== lastAction) {

View File

@@ -28,6 +28,8 @@ import {
} from '../help/forgeFormNormalize'; } from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton'; import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker'; import PoolPresetPicker from '../components/PoolPresetPicker';
@@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, 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'; const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean { function loadSimpleMode(): boolean {
@@ -139,8 +118,7 @@ function loadSimpleMode(): boolean {
export default function BuilderPage() { export default function BuilderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [form, setForm] = useState<BuildRequest | null>(null); const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false); const [building, setBuilding] = useState(false);
@@ -217,52 +195,7 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme); const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Poll real server-side build progress while a single build is running. useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef);
// 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]);
const setForgeMode = (simple: boolean) => { const setForgeMode = (simple: boolean) => {
setSimpleMode(simple); setSimpleMode(simple);
@@ -348,7 +281,7 @@ export default function BuilderPage() {
}, [searchParams, recentBuilds, form]); }, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => { const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100); setStage('Forged!', 100);
setLastBuild(result); setLastBuild(result);
setDispenseReveal(result); setDispenseReveal(result);
loadRecentBuilds(); loadRecentBuilds();
@@ -789,7 +722,6 @@ export default function BuilderPage() {
cancelToken, cancelToken,
onStep: (step) => { onStep: (step) => {
setMissionStep(step); setMissionStep(step);
if (step === 'forge') startForge();
}, },
}); });
setMissionExportSkipped(result.exportSkipped); setMissionExportSkipped(result.exportSkipped);

View File

@@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip'; import { HelpTip } from '../components/HelpTip';
import AlsoHere from '../components/Presence/AlsoHere'; import AlsoHere from '../components/Presence/AlsoHere';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { useModalAmbientDuck } from '../context/AmbientMusicContext'; import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useForge } from '../context/ForgeContext'; import { useForge } from '../context/ForgeContext';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
@@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, 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' { function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
if (chip === 'ghost') return 'cyan'; if (chip === 'ghost') return 'cyan';
if (chip === 'loud') return 'magenta'; if (chip === 'loud') return 'magenta';
@@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' |
export default function MissionDeckPage() { export default function MissionDeckPage() {
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelTokenRef = useRef(''); const cancelTokenRef = useRef('');
@@ -166,52 +147,10 @@ export default function MissionDeckPage() {
.catch(() => setError('Failed to load server config — is the control server running?')) .catch(() => setError('Failed to load server config — is the control server running?'))
.finally(() => setLoadingDefaults(false)); .finally(() => setLoadingDefaults(false));
}, []); }, []);
// Poll real server-side build progress (same as BuilderPage). useForgeProgressPoll(building, cancelTokenRef);
useEffect(() => {
if (!building) {
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]);
useEffect(() => { useEffect(() => {
return () => { return () => {
const tok = cancelTokenRef.current; const tok = cancelTokenRef.current;
if (tok) api.cancelBuild(tok).catch(() => {}); if (tok) api.cancelBuild(tok).catch(() => {});
}; };
@@ -247,7 +186,7 @@ export default function MissionDeckPage() {
}; };
const finishForgeSuccess = async (result: BuildResponse) => { const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100); setStage('Forged!', 100);
setDispenseReveal(result); setDispenseReveal(result);
}; };
@@ -301,7 +240,6 @@ export default function MissionDeckPage() {
cancelToken, cancelToken,
onStep: (step) => { onStep: (step) => {
setMissionStep(step); setMissionStep(step);
if (step === 'forge') startForge();
}, },
}); });
setMissionExportSkipped(result.exportSkipped); setMissionExportSkipped(result.exportSkipped);

View File

@@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint {
border-radius: 4px; border-radius: 4px;
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700); background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6); 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; position: relative;
z-index: 1; 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 { .forge-progress-glow {
position: absolute; position: absolute;
top: 50%; top: 50%;

View File

@@ -108,7 +108,7 @@ export default function ROIPage() {
? (hr / totalHashrate) * xmrPerDay ? (hr / totalHashrate) * xmrPerDay
: 0; : 0;
const nodeUsdDay = nodeXmrDay * price; 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 nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
const nodeKwhDay = (nodeWatts / 1000) * 24; const nodeKwhDay = (nodeWatts / 1000) * 24;
const nodeElecCost = nodeKwhDay * kwh; const nodeElecCost = nodeKwhDay * kwh;

View File

@@ -580,8 +580,8 @@ export default function SettingsPage() {
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive"> <NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Deck Atmosphere</h2> <h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc"> <p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on Rising 0/1 and hex glyphs follow your pointer on desktop deck pages. Background glow
low-power devices if you want a calmer deck. particles sit behind the UI. Turn off on low-power devices for a calmer deck.
</p> </p>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
<label className="checkbox-label"> <label className="checkbox-label">
@@ -591,7 +591,7 @@ export default function SettingsPage() {
checked={glowParticles} checked={glowParticles}
onChange={(e) => setGlowParticles(e.target.checked)} onChange={(e) => setGlowParticles(e.target.checked)}
/> />
<span>Glow particles &amp; sparkles</span> <span>Hacker cursor trail</span>
</label> </label>
</div> </div>
<div className="form-group" style={{ marginTop: '1.25rem' }}> <div className="form-group" style={{ marginTop: '1.25rem' }}>

View File

@@ -45,7 +45,7 @@ body {
touch-action: manipulation; touch-action: manipulation;
} }
.cursor-fire-fx { .cursor-hacker-fx {
display: none !important; display: none !important;
} }

View File

@@ -9,7 +9,7 @@ describe('visualPrefs', () => {
localStorage.clear(); localStorage.clear();
}); });
it('defaults glow particles to on', () => { it('defaults hacker cursor trail to on', () => {
expect(loadGlowParticlesEnabled()).toBe(true); expect(loadGlowParticlesEnabled()).toBe(true);
}); });

Binary file not shown.

View File

@@ -2,23 +2,52 @@
setlocal EnableExtensions EnableDelayedExpansion setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck title AetherForge Control Deck
cd /d "%~dp0" 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) :: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
set "ROOT="
if exist "%CD%\AetherForge.exe" ( if exist "%CD%\AetherForge.exe" (
set "ROOT=!CD!" set "ROOT=!CD!"
) else if exist "%CD%\usb\AetherForge.exe" ( ) else if exist "%CD%\usb\AetherForge.exe" (
cd /d "%CD%\usb" cd /d "%CD%\usb"
set "ROOT=!CD!" 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" ( if not exist "%ROOT%\AetherForge.exe" (
echo ERROR: AetherForge.exe missing in %ROOT% echo ERROR: AetherForge.exe missing in %ROOT%
pause pause
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
goto go_ready goto go_ready
) )
:: Check if Go is installed system-wide
where go >nul 2>nul where go >nul 2>nul
if not errorlevel 1 ( if not errorlevel 1 (
echo [Go] Using system Go installation. echo [Go] Using system Go installation.
@@ -58,7 +86,6 @@ if not errorlevel 1 (
goto go_ready goto go_ready
) )
:: Go not found anywhere - skip optional tools, proceed directly to server
echo. echo.
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them. echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
echo. echo.
@@ -66,17 +93,11 @@ goto server_launch
:go_ready :go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath" set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod" set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache" set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off" set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i not "%AF_INSTALL_TOOLS%"=="0" ( if /i not "%AF_INSTALL_TOOLS%"=="0" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" ( if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble... echo [Tools] Installing garble...
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
:server_launch :server_launch
:: ---------------------------------------------------------------- echo.
:: 4. Ensure data directories exist echo Ensuring data directories...
:: ----------------------------------------------------------------
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds" 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\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits" 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\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json" set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" ( if exist "%CONFIG_FILE%" (
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
:lan_done :lan_done
set "LAN_IP=%LAN_IP: =%" set "LAN_IP=%LAN_IP: =%"
:: ---------------------------------------------------------------- echo Stopping stale processes...
:: 6. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
taskkill /F /IM AetherForge.exe >nul 2>nul taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >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 Data: %ROOT%\data\
echo. echo.
echo Login accounts: admin + comrade ^(passwords below after start^). echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop. echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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" set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" 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%" ( if exist "%CF_SCRIPT%" (
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
) )
echo. echo.
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
cd /d "%ROOT%" cd /d "%ROOT%"
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data" "%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" ( call :cleanup_tunnel "%ROOT%"
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do ( 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 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 taskkill /F /IM cloudflared.exe >nul 2>nul
exit /b 0
:server_stopped
echo. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.
) else ( ) else (
echo [Server] Exited with code !EC!. 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. echo.