Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 7 additions & 139 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,10 @@

logger = logging.getLogger(__name__)

# Grace period after starting xmrig during which we poll for an immediate
# crash. Anything that exits inside this window almost certainly has a config
# or permission problem we want to surface, not hide behind a "running" menu
# state. Matches the original 0.25s sleep.
STARTUP_GRACE_SECONDS = 0.25
STARTUP_CHECK_INTERVAL = 0.05

# Timeouts used when stopping xmrig for shutdown paths (quit, exception
# handler) where the user is waiting on the app to exit. Kept short so the
# total wait stays well under macOS's 5s unresponsiveness threshold for a
# blocking main-thread Quit handler.
SHUTDOWN_SIGTERM_TIMEOUT = 1
SHUTDOWN_SIGKILL_TIMEOUT = 0.5

# Timeouts used for the user-facing toggle path. A bit more generous so a
# well-behaved xmrig can wind down its workers cleanly.
TOGGLE_SIGTERM_TIMEOUT = 3
TOGGLE_SIGKILL_TIMEOUT = 2

Expand All @@ -35,36 +23,20 @@ def init(filename="xmmanager.log"):


def find_xmrig():
"""Locate the xmrig binary on disk. Returns an absolute path or None.

GUI apps launched by Launch Services on macOS inherit a stripped $PATH
(just /usr/bin:/bin:/usr/sbin:/sbin), so the standard "which" approach
won't see Homebrew, MacPorts, or user bin dirs. We check a fixed list
of common install locations instead.

Search order:
1. $XMRIG_PATH env var (explicit override)
2. Homebrew Apple Silicon: /opt/homebrew/bin/xmrig
3. Homebrew Intel: /usr/local/bin/xmrig
4. MacPorts: /opt/local/bin/xmrig
5. User bin dirs: ~/bin/xmrig, ~/.local/bin/xmrig
6. Adjacent to the .app (legacy "same folder" install)
7. Next to main.py (dev fallback when running from source)
"""
def _is_xmrig(path):
return (
path
and os.path.isfile(path)
and os.access(path, os.X_OK)
)

# 1. Explicit env override wins.
# env
env = os.environ.get("XMRIG_PATH")
if _is_xmrig(env):
logger.info(f"Using xmrig from XMRIG_PATH: {env}")
return env

# 2-4. Homebrew and MacPorts.
# brew/ports
for path in (
"/opt/homebrew/bin/xmrig",
"/usr/local/bin/xmrig",
Expand All @@ -74,17 +46,16 @@ def _is_xmrig(path):
logger.info(f"Found xmrig at {path}")
return path

# 5. User bin dirs.
# bin
home = os.path.expanduser("~")
for path in (
os.path.join(home, "bin", "xmrig"),
os.path.join(home, ".local", "bin", "xmrig"),
os.path.join(home, "bin", "xmrig"),
):
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if _is_xmrig(path):
logger.info(f"Found xmrig at {path}")
return path

# 6. Legacy: xmrig sitting next to XMManager.app in the same folder.
# next to .app
if getattr(sys, "frozen", False):
bundle = os.path.dirname(
os.path.dirname(os.path.dirname(sys.executable))
Expand All @@ -93,8 +64,7 @@ def _is_xmrig(path):
if _is_xmrig(adjacent):
logger.info(f"Found xmrig at {adjacent}")
return adjacent

# 7. Dev fallback when running from source.
# next to main
if not getattr(sys, "frozen", False):
dev_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "xmrig"
Expand All @@ -104,7 +74,7 @@ def _is_xmrig(path):
return dev_path

logger.error(
"xmrig not found in any standard location. Install via "
"XMRig not found. Install via "
"`brew install xmrig` or set the XMRIG_PATH environment variable."
)
return None
Expand All @@ -113,14 +83,7 @@ def _is_xmrig(path):
class Main(rumps.App):
def __init__(self, *args, xmrig_path=None, **kwargs):
super().__init__(*args, **kwargs)
# Resolved path to the xmrig binary, or None if it could not be
# located anywhere. The Main.__init__ caller (see __main__) is
# responsible for searching; we just store the result.
self.xmrig_path = xmrig_path
# XMRig defaults to looking for ./config.json in the CWD, which for
# a Launch Services-launched menu bar app is "/" and not useful.
# Hardcode the user's home dir so a single config file works
# regardless of where the .app is installed.
self.xmrig_config = os.path.expanduser("~/.xmrig.json")
self.xmrig_command = (
[
Expand All @@ -131,43 +94,20 @@ def __init__(self, *args, xmrig_path=None, **kwargs):
if xmrig_path
else None
)
# Popen handle for the running xmrig, or None when stopped.
# Source of truth for the toggle state.
self.xmrig_process = None
# rumps.Timer used to poll xmrig during the startup grace window.
# Reused across restarts; we always stop() before start() to avoid
# leaking NSTimers into the run loop.
self._startup_check_timer = rumps.Timer(
self._check_xmrig_startup, STARTUP_CHECK_INTERVAL
)
# Counter of remaining seconds (in check-interval units) the grace
# timer should keep firing for the most recent start.
self._grace_remaining = 0
# Handle the grace timer was last armed for. Used by the callback
# to detect that the user has already taken action (reconciled a
# crash, or started a new process) so the timer does not double-
# notify or clobber a fresh toggle state.
self._grace_target = None

# If xmrig was not found at startup, schedule a one-shot quit
# notification. We can't call rumps.notification + quit_application
# from __init__ because the NSApp run loop hasn't started yet, so
# the notification would be dropped and terminate_() would target
# an unstarted app. The timer fires once the run loop is alive.
self._missing_quit_timer = rumps.Timer(
self._handle_missing_xmrig, 0.1
)

@rumps.clicked("Toggle XMRig")
def mining_controller(self, sender):
# Reconcile stale menu state. rumps does NOT auto-toggle a checkmark
# on click, so if xmrig died unexpectedly the checkmark can lie.
# Trust process reality over the menu state, but only flip the menu
# back when we are sure the user did not just click "stop".
if sender.state and not self._is_running():
logger.warning("Toggle was checked but xmrig is not running; reconciling")
# Clear the dead handle and cancel the grace timer so its
# pending tick (if any) does not double-notify the user.
self.xmrig_process = None
self._startup_check_timer.stop()
self._grace_target = None
Expand All @@ -181,9 +121,6 @@ def mining_controller(self, sender):

if self._is_running():
self._stop_xmrig_async()
# Leave the checkmark visible during the brief cleanup window so
# the menu does not flicker. The background thread will clear it
# via AppHelper.callAfter once the process is actually gone.
return

if self._start_xmrig():
Expand All @@ -198,11 +135,6 @@ def mining_controller(self, sender):

@rumps.clicked("Quit")
def on_quit(self, _):
# Synchronous shutdown. A daemon background thread would be killed
# by the Python interpreter during Cocoa app teardown, leaving
# xmrig running as an orphan. We accept a short blocking wait on
# the main thread so SIGTERM/SIGKILL actually land before the app
# exits. SHUTDOWN_* timeouts keep the total under ~1.5s.
try:
self._stop_xmrig_sync(
sigterm_timeout=SHUTDOWN_SIGTERM_TIMEOUT,
Expand All @@ -214,10 +146,6 @@ def on_quit(self, _):
rumps.quit_application()

def run(self, *args, **kwargs):
# If xmrig was not found at startup, fire the missing-quit timer
# so it can post a notification and exit the app cleanly. Started
# here (not in __init__) because the NSApp run loop has to be
# running before NSTimers can be scheduled against it.
if self.xmrig_path is None:
self._missing_quit_timer.start()
super().run(*args, **kwargs)
Expand All @@ -244,26 +172,9 @@ def _is_running(self):
return proc is not None and proc.poll() is None

def _set_toggle_state(self, new_state):
"""Set the checkmark on the toggle menu item.

Must be called on the main thread. Current call sites all satisfy
that: rumps click handlers run on the main run loop, the grace
timer's callback fires on the main run loop, and the background
_stop_xmrig_async thread hops back here via AppHelper.callAfter
before calling this method.
"""
# self.menu is a dict-like view of the NSMenu built at run() time.
# Look up the item by title so timer / background-thread callbacks
# can update state without a sender reference.
self.menu["Toggle XMRig"].state = new_state

def _start_xmrig(self):
"""Start xmrig. Returns True once Popen succeeds and the grace
timer has been armed. The grace timer is responsible for detecting
an immediate crash and reverting the toggle state."""
# Defensive: should be impossible because the app quits via the
# missing-quit timer when xmrig_path is None, but guard anyway so
# a stray click can't crash the process with a TypeError.
if self.xmrig_command is None:
logger.error("Cannot start xmrig: binary path not resolved")
return False
Expand All @@ -274,17 +185,12 @@ def _start_xmrig(self):
self.xmrig_command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# Put xmrig in its own process group so signals we receive
# on the menu bar app don't leak into the child.
start_new_session=True,
)
except OSError as e:
logger.error(f"Error starting xmrig: {e}")
self.xmrig_process = None
return False

# Arm the grace timer. Runs on the main run loop, so it is safe to
# touch menu state from the callback without thread-safe dispatch.
self._grace_target = self.xmrig_process
self._grace_remaining = STARTUP_GRACE_SECONDS
self._startup_check_timer.stop()
Expand All @@ -294,21 +200,9 @@ def _start_xmrig(self):
return True

def _check_xmrig_startup(self, _timer):
"""Timer callback fired on the main run loop during the startup
grace window. If xmrig died before the window expired, revert the
toggle state so the menu does not lie about whether mining is on."""
# If the process we were watching is no longer the live one, the
# user has already acted (reconciled a crash, or started a fresh
# process) — bail so we don't double-notify or clobber the new
# toggle state. Also covers the case where the process was
# stopped before the grace window elapsed.
if self._grace_target is not self.xmrig_process:
self._startup_check_timer.stop()
return

# Crashed early? Revert the toggle so the UI matches reality.
# Checked before the grace-expiry branch so a crash on the final
# tick is not silently swallowed by the time-up early return.
if self.xmrig_process.poll() is not None:
rc = self.xmrig_process.returncode
logger.error(f"xmrig exited immediately with code {rc}")
Expand All @@ -322,30 +216,16 @@ def _check_xmrig_startup(self, _timer):
)
return

# Time's up: xmrig survived the grace window, treat it as running.
self._grace_remaining -= STARTUP_CHECK_INTERVAL
if self._grace_remaining <= 0:
self._startup_check_timer.stop()

def _stop_xmrig_async(self):
"""Stop xmrig on a background thread. Safe to call from main-thread
callbacks (rumps click handlers) because the actual terminate/wait/
kill can take up to ~5s and we must not block the macOS main run
loop. Quit uses _stop_xmrig_sync instead because the interpreter
kills daemon threads during shutdown."""
proc = self.xmrig_process
if proc is None or proc.poll() is not None:
self.xmrig_process = None
return

# Cancel the grace timer. If the user stops during the startup
# window, the timer would otherwise fire on the next tick, see the
# process exiting via SIGTERM, and post a spurious "XMRig stopped
# unexpectedly" notification.
self._startup_check_timer.stop()

# Snapshot the handle so a subsequent start (e.g. user toggles back
# on before cleanup finishes) cannot race with this thread.
target = proc

def _shutdown():
Expand All @@ -360,14 +240,8 @@ def _shutdown():
except (OSError, ProcessLookupError, subprocess.TimeoutExpired) as e:
logger.error(f"Error stopping xmrig: {e}")
finally:
# Only clear the live handle and update the menu if xmrig
# is still the one we were stopping. A restart in the
# meantime would have replaced self.xmrig_process with a
# new Popen and the new Popen already owns the UI state
# — flipping the checkmark here would lie about it.
if self.xmrig_process is target:
self.xmrig_process = None
# Hop back to the main thread for the menu update.
AppHelper.callAfter(self._set_toggle_state, False)
logger.info("Stopped XMRig")
else:
Expand Down Expand Up @@ -405,9 +279,6 @@ def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout):
main_app = None
try:
init()
# Resolve xmrig once at startup. If the binary is missing, Main
# will show a notification and call rumps.quit_application() from
# a timer started inside run() — no special-casing needed here.
xmrig = find_xmrig()
main_app = Main("XMManager", xmrig_path=xmrig, quit_button=None)
main_app.run()
Expand All @@ -420,9 +291,6 @@ def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout):
sigkill_timeout=SHUTDOWN_SIGKILL_TIMEOUT,
)
except Exception as stop_err:
# Never let a stop failure prevent the force-exit; an
# orphaned xmrig is the lesser evil compared to a
# process that won't quit at all.
logger.error(f"Error stopping xmrig in __main__ handler: {stop_err}")
logger.info("Force exiting...")
os._exit(0)
Loading