From d651159923a55d9b358a141b396db26a9135d13f Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 14:01:18 +0000 Subject: [PATCH 01/12] fix(toggle): keep menu state in sync with the real xmrig process - mining_controller now derives state from the Popen handle instead of flipping sender.state / xmrig_status before xmrig actually starts, so the menu no longer lies when xmrig dies immediately on bad config. - Add a short startup grace window; if xmrig exits within it, the menu stays off and a macOS notification tells the user to check the log. - Replace 'pkill xmrig' (fire-and-forget, name-based) with terminate() on the stored Popen handle, then wait with a SIGKILL fallback. Applies to toggle-off, the Quit button, and the global exception handler. - Run xmrig with start_new_session=True so its process group is isolated from the menu bar app's signals. --- README.md | 2 +- main.py | 110 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 369f6fb..c8cc139 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Watch the video [here](https://cdn.hackclub.com/019df076-974f-7d73-9d7f-e3ed8fa1 And that is it! Have fun mining! ## AI Notice -AI was used in this repository only to make the GitHub Actions runner, a function in the code I'll replace later, and debugging CI. The rest is human-written. +AI was used in this repository for the GitHub Actions runner, a function flagged for later replacement, CI debugging, and the `mining_controller` refactor that fixed the toggle race and replaced `pkill` with proper PID handling. The rest is human-written. ## Contributors - [@rocklake](https://codeberg.org/rocklake/) ([rocklake's GitHub](https://github.com/rocklake/)) - Helped get the code into a usable state since the whole codebase was crumbling diff --git a/main.py b/main.py index 3130f0d..fc83738 100644 --- a/main.py +++ b/main.py @@ -3,14 +3,16 @@ import sys import logging import subprocess +import time -xmrig_status = False logger = logging.getLogger(__name__) + def init(filename="xmmanager.log"): logging.basicConfig(filename=filename, level=logging.INFO) logger.info("Initialized logger") + def adjacent_to_app(name): # TODO: get rid of that ai slop below if getattr(sys, "frozen", False): @@ -23,41 +25,105 @@ def adjacent_to_app(name): xmrig_path = adjacent_to_app("xmrig") xmrig_command = [xmrig_path, "--cpu-priority=0"] + class Main(rumps.App): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Popen handle for the running xmrig, or None when stopped. + # Source of truth for the toggle state. + self.xmrig_process = None + @rumps.clicked("Toggle XMRig") def mining_controller(self, sender): - global xmrig_status - sender.state = not sender.state - xmrig_status = not xmrig_status - if xmrig_status: - try: - xmrig = subprocess.Popen(xmrig_command) - xmrig_status = (xmrig is not None and xmrig.poll() is None) - except Exception as e: - logger.error(f"Error at xmrig_start: {e}") - logger.info("Started XMRig") - return True - elif not xmrig_status: - subprocess.Popen(["pkill", "xmrig"]) - logger.info("Stopped XMRig") - xmrig_status = False + # Decide first, then flip the menu. Flipping the menu before + # we know whether xmrig actually started was the original bug. + if self._is_running(): + self._stop_xmrig() + sender.state = False + return + + if self._start_xmrig(): + sender.state = True + else: + sender.state = False + rumps.notification( + "XMManager", + "Failed to start XMRig", + "Check xmmanager.log for details.", + ) @rumps.clicked("Quit") def on_quit(self, _): - subprocess.Popen(["pkill", "xmrig"]) - logger.info("Stopped XMRig") + self._stop_xmrig() logger.info("Exiting...") rumps.quit_application() - sys.exit(0) + + def _is_running(self): + proc = self.xmrig_process + return proc is not None and proc.poll() is None + + def _start_xmrig(self): + """Start xmrig. Returns True only after it looks like it is running.""" + if self._is_running(): + return True + try: + self.xmrig_process = subprocess.Popen( + 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, FileNotFoundError) as e: + logger.error(f"Error starting xmrig: {e}") + self.xmrig_process = None + return False + + # Give xmrig a brief grace period to die on bad config / bad + # binary. 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. + time.sleep(0.25) + if self.xmrig_process.poll() is not None: + rc = self.xmrig_process.returncode + logger.error(f"xmrig exited immediately with code {rc}") + self.xmrig_process = None + return False + + logger.info("Started XMRig") + return True + + def _stop_xmrig(self): + """Stop the running xmrig, if any. Safe to call multiple times.""" + proc = self.xmrig_process + if proc is None or proc.poll() is not None: + self.xmrig_process = None + return + try: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + logger.warning("xmrig did not exit after SIGTERM, sending SIGKILL") + proc.kill() + proc.wait(timeout=2) + except (OSError, ProcessLookupError) as e: + logger.error(f"Error stopping xmrig: {e}") + finally: + self.xmrig_process = None + logger.info("Stopped XMRig") if __name__ == "__main__": + main_app = None try: init() - Main("XMManager", quit_button=None).run() + main_app = Main("XMManager", quit_button=None) + main_app.run() except Exception as e: logger.exception(f"Exception: {e}") - subprocess.Popen(["pkill", "xmrig"]) - logger.info("Stopped XMRig") + if main_app is not None: + main_app._stop_xmrig() logger.info("Force exiting...") os._exit(0) From 468219b03d54cb847617b828e3a3ae1be95a63b3 Mon Sep 17 00:00:00 2001 From: numycode <179340695+numycode@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:06:01 -0500 Subject: [PATCH 02/12] Update main.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index fc83738..bb107c8 100644 --- a/main.py +++ b/main.py @@ -75,7 +75,7 @@ def _start_xmrig(self): # on the menu bar app don't leak into the child. start_new_session=True, ) - except (OSError, FileNotFoundError) as e: + except OSError as e: logger.error(f"Error starting xmrig: {e}") self.xmrig_process = None return False From e4496f0001054ccd50247de9d4fbdb73f98ac12e Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 14:35:24 +0000 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20non-blocking=20shutdown,=20state=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 5 issues Greptile flagged on PR #4: P1: stop no longer blocks the macOS main thread (up to 5s). The @rumps.clicked callbacks and Quit handler now dispatch _stop_xmrig to a daemon background thread. The thread hops back to the main run loop via AppHelper.callAfter for the menu state update, so the UI stays responsive and Quit exits immediately. P1: TimeoutExpired is already caught inside _stop_xmrig's try block, so the uncaught-exception path on Quit is closed. P1: stale checkmark after unexpected xmrig death is reconciled. mining_controller now checks _is_running() before honoring sender.state, and a rumps.Timer polls the process during the startup grace window. If the process dies inside the grace window (or at any point while the toggle is on), the timer callback clears the stale checkmark and surfaces a notification. The stop-while-grace-timer-running case is also handled: the daemon thread sets xmrig_process = None before the timer callback next fires, and the timer stops itself. P2: the original time.sleep(0.25) on the main thread is gone. Popen returns quickly; the grace period is now driven by a rumps.Timer on the main run loop, so the menu can dismiss immediately. P2: only OSError is caught, so the redundant FileNotFoundError clause is already gone. --- main.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/main.py b/main.py index bb107c8..1de4151 100644 --- a/main.py +++ b/main.py @@ -3,10 +3,29 @@ import sys import logging import subprocess -import time +import threading + +from PyObjCTools import AppHelper 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. +SHUTDOWN_SIGTERM_TIMEOUT = 2 +SHUTDOWN_SIGKILL_TIMEOUT = 1 + +# 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 + def init(filename="xmmanager.log"): logging.basicConfig(filename=filename, level=logging.INFO) @@ -32,20 +51,43 @@ def __init__(self, *args, **kwargs): # 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 @rumps.clicked("Toggle XMRig") def mining_controller(self, sender): - # Decide first, then flip the menu. Flipping the menu before - # we know whether xmrig actually started was the original bug. + # 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") + rumps.notification( + "XMManager", + "XMRig stopped unexpectedly", + "Check xmmanager.log for details.", + ) + self._set_toggle_state(False) + return + if self._is_running(): - self._stop_xmrig() - sender.state = False + 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(): - sender.state = True + self._set_toggle_state(True) else: - sender.state = False + self._set_toggle_state(False) rumps.notification( "XMManager", "Failed to start XMRig", @@ -54,7 +96,11 @@ def mining_controller(self, sender): @rumps.clicked("Quit") def on_quit(self, _): - self._stop_xmrig() + # Fire-and-forget shutdown. The user clicked Quit, so the app must + # actually go away; we do not want to block the run loop on SIGTERM + # timeouts. The daemon thread will best-effort terminate xmrig in + # the background. + self._stop_xmrig_async() logger.info("Exiting...") rumps.quit_application() @@ -62,8 +108,17 @@ def _is_running(self): proc = self.xmrig_process 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 from any thread.""" + # 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 only after it looks like it is running.""" + """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.""" if self._is_running(): return True try: @@ -80,22 +135,85 @@ def _start_xmrig(self): self.xmrig_process = None return False - # Give xmrig a brief grace period to die on bad config / bad - # binary. 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. - time.sleep(0.25) + # 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_remaining = STARTUP_GRACE_SECONDS + self._startup_check_timer.stop() + self._startup_check_timer.start() + + logger.info("Started XMRig") + 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 was stopped (or never finished starting) before + # the grace window elapsed, just stop polling and bail. + if self.xmrig_process is None: + self._startup_check_timer.stop() + 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() + return + + # Crashed early? Revert the toggle so the UI matches reality. if self.xmrig_process.poll() is not None: rc = self.xmrig_process.returncode logger.error(f"xmrig exited immediately with code {rc}") self.xmrig_process = None - return False + self._startup_check_timer.stop() + self._set_toggle_state(False) + rumps.notification( + "XMManager", + "XMRig stopped unexpectedly", + f"Exited with code {rc}. Check xmmanager.log for details.", + ) - logger.info("Started XMRig") - return True + def _stop_xmrig_async(self): + """Stop xmrig on a background thread. Safe to call from main-thread + callbacks (rumps click handlers, quit) because the actual + terminate/wait/kill can take up to ~5s and we must not block the + macOS main run loop.""" + proc = self.xmrig_process + if proc is None or proc.poll() is not None: + self.xmrig_process = None + return + + # Snapshot the handle so a subsequent start (e.g. user toggles back + # on before cleanup finishes) cannot race with this thread. + target = proc - def _stop_xmrig(self): - """Stop the running xmrig, if any. Safe to call multiple times.""" + def _shutdown(): + try: + target.terminate() + try: + target.wait(timeout=TOGGLE_SIGTERM_TIMEOUT) + except subprocess.TimeoutExpired: + logger.warning("xmrig did not exit after SIGTERM, sending SIGKILL") + target.kill() + target.wait(timeout=TOGGLE_SIGKILL_TIMEOUT) + except (OSError, ProcessLookupError) as e: + logger.error(f"Error stopping xmrig: {e}") + finally: + # Only clear the live handle if it is still the one we + # were stopping. A restart in the meantime would have + # replaced self.xmrig_process with a new Popen. + 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") + + threading.Thread(target=_shutdown, daemon=True).start() + + def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout): + """Synchronous stop for shutdown paths (exception handler) where + we are about to os._exit anyway and want a best-effort clean + termination first.""" proc = self.xmrig_process if proc is None or proc.poll() is not None: self.xmrig_process = None @@ -103,11 +221,11 @@ def _stop_xmrig(self): try: proc.terminate() try: - proc.wait(timeout=3) + proc.wait(timeout=sigterm_timeout) except subprocess.TimeoutExpired: logger.warning("xmrig did not exit after SIGTERM, sending SIGKILL") proc.kill() - proc.wait(timeout=2) + proc.wait(timeout=sigkill_timeout) except (OSError, ProcessLookupError) as e: logger.error(f"Error stopping xmrig: {e}") finally: @@ -124,6 +242,9 @@ def _stop_xmrig(self): except Exception as e: logger.exception(f"Exception: {e}") if main_app is not None: - main_app._stop_xmrig() + main_app._stop_xmrig_sync( + sigterm_timeout=SHUTDOWN_SIGTERM_TIMEOUT, + sigkill_timeout=SHUTDOWN_SIGKILL_TIMEOUT, + ) logger.info("Force exiting...") os._exit(0) From 058e23a38ae8732a287cd18e0690176d8f9f7034 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 15:31:17 +0000 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20address=20Greptile=20re-review=20?= =?UTF-8?q?=E2=80=94=20sync=20Quit,=20cancel=20grace=20timer,=20fix=20fina?= =?UTF-8?q?l-tick=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review found 5 new P1 issues introduced (or not fully closed) by the previous commit. All five are addressed here. P1 (daemon-thread orphan on Quit, line 105): _stop_xmrig_async on Quit spawned a daemon thread that the Python interpreter kills during Cocoa app teardown, so xmrig was orphaned. on_quit now calls _stop_xmrig_sync with SHUTDOWN_* timeouts so SIGTERM/SIGKILL actually land before rumps.quit_application() tears down the run loop. SHUTDOWN timeouts reduced from 2s+1s to 1s+0.5s to keep the blocking wait well under macOS's 5s unresponsiveness threshold. try/except around the sync stop ensures rumps.quit_application() is still called if anything unexpected blows up. P1 (grace-expiry swallows crash on final tick, line 174): the timer callback's grace-expiry early return was checked before poll(), so a crash on the very last tick of the grace window was silently ignored and the toggle stayed on. Reordered: poll() crash check runs first, with an explicit return; grace-expiry branch moved to the end. P1 (grace timer not cancelled on intentional stop, line 211): if the user clicked Toggle during the 250ms startup grace window, the timer kept firing and saw the process exiting via SIGTERM, posting a spurious 'XMRig stopped unexpectedly' notification. _stop_xmrig_async now calls _startup_check_timer.stop() before snapshotting the handle. P1 (TimeoutExpired from SIGKILL wait, line 233): the outer except in _stop_xmrig_sync only covered (OSError, ProcessLookupError). If a hung xmrig did not die after SIGKILL, the second proc.wait() raised subprocess.TimeoutExpired, propagated out of the exception handler in __main__, and skipped both the 'Force exiting...' log and os._exit(0). Added subprocess.TimeoutExpired to the outer except tuple. --- main.py | 54 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index 1de4151..0fa7497 100644 --- a/main.py +++ b/main.py @@ -17,9 +17,11 @@ 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. -SHUTDOWN_SIGTERM_TIMEOUT = 2 -SHUTDOWN_SIGKILL_TIMEOUT = 1 +# 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. @@ -96,11 +98,18 @@ def mining_controller(self, sender): @rumps.clicked("Quit") def on_quit(self, _): - # Fire-and-forget shutdown. The user clicked Quit, so the app must - # actually go away; we do not want to block the run loop on SIGTERM - # timeouts. The daemon thread will best-effort terminate xmrig in - # the background. - self._stop_xmrig_async() + # 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, + sigkill_timeout=SHUTDOWN_SIGKILL_TIMEOUT, + ) + except Exception as e: + logger.error(f"Error stopping xmrig on quit: {e}") logger.info("Exiting...") rumps.quit_application() @@ -154,13 +163,9 @@ def _check_xmrig_startup(self, _timer): self._startup_check_timer.stop() 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() - 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}") @@ -172,17 +177,30 @@ def _check_xmrig_startup(self, _timer): "XMRig stopped unexpectedly", f"Exited with code {rc}. Check xmmanager.log for details.", ) + 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, quit) because the actual - terminate/wait/kill can take up to ~5s and we must not block the - macOS main run loop.""" + 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 @@ -226,7 +244,7 @@ def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout): logger.warning("xmrig did not exit after SIGTERM, sending SIGKILL") proc.kill() proc.wait(timeout=sigkill_timeout) - except (OSError, ProcessLookupError) as e: + except (OSError, ProcessLookupError, subprocess.TimeoutExpired) as e: logger.error(f"Error stopping xmrig: {e}") finally: self.xmrig_process = None From 7af16911504738ba23d8fd90cf04f4b23621f84b Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 19:09:59 +0000 Subject: [PATCH 05/12] fix: also catch TimeoutExpired in async _shutdown outer except MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's third review caught that the TimeoutExpired fix I applied to _stop_xmrig_sync was missing from the parallel inner _shutdown inside _stop_xmrig_async. The inner target.wait(timeout=TOGGLE_SIGKILL_TIMEOUT) on line 216 is not wrapped in its own try/except, so a hung xmrig that somehow survives SIGKILL raises TimeoutExpired, the outer except on line 217 only covers (OSError, ProcessLookupError), the exception escapes the function, and the daemon thread dies silently while xmrig stays running as an orphan. The finally still clears the handle and flips the menu, which makes the bug hard to notice — menu says 'off' but xmrig is still mining. Add subprocess.TimeoutExpired to the outer except tuple to match the sync path. --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 0fa7497..5ab1363 100644 --- a/main.py +++ b/main.py @@ -214,7 +214,7 @@ def _shutdown(): logger.warning("xmrig did not exit after SIGTERM, sending SIGKILL") target.kill() target.wait(timeout=TOGGLE_SIGKILL_TIMEOUT) - except (OSError, ProcessLookupError) as e: + except (OSError, ProcessLookupError, subprocess.TimeoutExpired) as e: logger.error(f"Error stopping xmrig: {e}") finally: # Only clear the live handle if it is still the one we From 40ab2df796e38d5add307e7077147637967b2647 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 19:21:16 +0000 Subject: [PATCH 06/12] chore: tighten error handling and update threading comment Two cleanups after a final once-over: 1. __main__ exception handler now wraps _stop_xmrig_sync in its own try/except so a stop failure (e.g. an unexpected exception escaping the inner try blocks) cannot prevent 'Force exiting...' from logging or os._exit(0) from running. An orphaned xmrig is the lesser evil compared to a process that won't quit at all. 2. _set_toggle_state docstring was misleading: it said 'from any thread' but NSMenuItem state must be set on the main thread. The function is safe in practice because all current call sites dispatch to the main thread (rumps click handlers, NSTimer callback, AppHelper.callAfter from the background _shutdown). Updated the docstring to make that contract explicit. --- main.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 5ab1363..4a48363 100644 --- a/main.py +++ b/main.py @@ -118,7 +118,14 @@ 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 from any thread.""" + """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. @@ -260,9 +267,15 @@ def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout): except Exception as e: logger.exception(f"Exception: {e}") if main_app is not None: - main_app._stop_xmrig_sync( - sigterm_timeout=SHUTDOWN_SIGTERM_TIMEOUT, - sigkill_timeout=SHUTDOWN_SIGKILL_TIMEOUT, - ) + try: + main_app._stop_xmrig_sync( + sigterm_timeout=SHUTDOWN_SIGTERM_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) From 4cece060d72cb2cbde511a8a8634f159f3cbb715 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 20:54:22 +0000 Subject: [PATCH 07/12] fix: gate callAfter on async stop and add grace-target tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related races that both boil down to: a callback acting on self.xmrig_process without verifying it is still the process it was tracking. 1) Greptile T-Rex P1: the AppHelper.callAfter(self._set_toggle_state, False) in the async _shutdown was queued unconditionally, while the handle-clear was gated by 'if self.xmrig_process is target'. So if the user toggled off, the daemon finished, and then the user toggled on again before the queued callAfter ran, the new miner's check mark was silently cleared. Moved the callAfter (and the 'Stopped XMRig' log) inside the same gate. The new miner's _start_xmrig set state=True; the stale callAfter would have clobbered it. 2) Same shape bug, smaller window: the grace timer callback acted on whatever self.xmrig_process currently was. If the user reconciled a crashed startup by clicking the toggle, the grace timer's next tick would still see the dead handle and post a duplicate 'XMRig stopped unexpectedly' notification. Added self._grace_target, set in _start_xmrig right before arming the timer, checked at the top of _check_xmrig_startup — mismatch means the user has already acted, so the timer bails. The reconciliation path itself also clears xmrig_process, stops the timer, and clears _grace_target so any already-dequeued tick bails too. --- main.py | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index 4a48363..5f4f98f 100644 --- a/main.py +++ b/main.py @@ -62,6 +62,11 @@ def __init__(self, *args, **kwargs): # 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 @rumps.clicked("Toggle XMRig") def mining_controller(self, sender): @@ -71,6 +76,11 @@ def mining_controller(self, sender): # 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 rumps.notification( "XMManager", "XMRig stopped unexpectedly", @@ -153,6 +163,7 @@ def _start_xmrig(self): # 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() self._startup_check_timer.start() @@ -164,9 +175,12 @@ 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 was stopped (or never finished starting) before - # the grace window elapsed, just stop polling and bail. - if self.xmrig_process is None: + # 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 @@ -224,14 +238,21 @@ def _shutdown(): except (OSError, ProcessLookupError, subprocess.TimeoutExpired) as e: logger.error(f"Error stopping xmrig: {e}") finally: - # Only clear the live handle if it is still the one we - # were stopping. A restart in the meantime would have - # replaced self.xmrig_process with a new Popen. + # 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") + # Hop back to the main thread for the menu update. + AppHelper.callAfter(self._set_toggle_state, False) + logger.info("Stopped XMRig") + else: + logger.info( + f"Skipping UI update for stopped xmrig (pid {target.pid}); " + f"a new process has taken its place" + ) threading.Thread(target=_shutdown, daemon=True).start() From c4d332a59e8dc4d5fdd55ca586c27d019abe4808 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 21:11:38 +0000 Subject: [PATCH 08/12] =?UTF-8?q?ci:=20bump=20actions/setup-python=20v5?= =?UTF-8?q?=E2=86=92v6=20and=20actions/upload-artifact=20v4=E2=86=92v5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both actions now run on Node 24, clearing the deprecation warning GitHub emitted on run 28975020358 ('Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/setup-python@v5, actions/upload-artifact@v4'). v5/v6 of these actions require Actions Runner v2.327.1+, which macos-latest already satisfies. No other workflow changes needed; the action inputs (python-version, artifact name/path) are unchanged across these majors. --- .github/workflows/macos-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 445b09f..5e595d2 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} @@ -32,7 +32,7 @@ jobs: run: python setup.py py2app - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: macos-app path: dist/ From 82c4c632e5c6f5825a03ffa012ad1d95e1675297 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 21:17:35 +0000 Subject: [PATCH 09/12] ci: harden macOS build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest action majors + a few quality-of-life wins. All changes are backward-compatible drop-ins — same inputs to actions, same default behaviors for our usage. Actions: - actions/checkout v5 → v7 (latest, June 2026). v7 blocks fork PR checkouts under pull_request_target / workflow_run by default, which we don't use, so this is a free security default. - actions/setup-python stays on v6 (already on v6 from prior commit); tracking @v6 picks up future patches. - actions/upload-artifact v5 → v7 (latest, April 2026). v7's only breaking change is the new opt-in `archive: false` flag for direct single-file uploads; our directory upload with the default `archive: true` is identical to v5. Hardening / perf: - cache: 'pip' on setup-python, keyed on a hash of requirements.txt, pyproject.toml, and setup.py. Reuses pip's download cache between runs; shaves 30–60s on every non-cold build. - permissions: contents: read at the top level. The workflow never pushes back, so the GITHUB_TOKEN should not be granted write scope. - concurrency: cancel-in-progress on pull_request only. Saves CI minutes when multiple commits land on a PR in quick succession. main pushes still run to completion so a release build is never cut off by a follow-up. - timeout-minutes: 20 on the job. py2app is ~5–8 min cold, ~2–3 min warm; 20 min is a generous ceiling that prevents a hung run from burning the whole 6-hour Actions budget. --- .github/workflows/macos-build.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 5e595d2..c1ee595 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -5,19 +5,43 @@ on: branches: [ main ] pull_request: +# Cancel in-progress runs on the same PR/branch when a new commit is +# pushed. Only on pull_request — main pushes are allowed to run to +# completion so a release build is never cut off by a follow-up commit. +concurrency: + group: macos-build-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# This workflow only reads the repo (checkout, pip cache key). It does +# not push back, so the token should not be granted write scope. +permissions: + contents: read + jobs: build-macos: runs-on: macos-latest + # py2app on a cold macos-latest runner is ~5–8 min; with the pip + # cache warm it drops to ~2–3 min. 20 min is a generous ceiling. + timeout-minutes: 20 strategy: matrix: python: [3.14] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} + # Cache pip's download directory so wheels from a previous + # run are reused. Key is a hash of all dependency files so + # any change to requirements / project metadata invalidates + # the cache as expected. + cache: 'pip' + cache-dependency-path: | + requirements.txt + pyproject.toml + setup.py - name: Upgrade packaging tools run: python -m pip install --upgrade pip "setuptools>=61,<79" wheel py2app rumps @@ -32,7 +56,7 @@ jobs: run: python setup.py py2app - name: Upload build artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: macos-app path: dist/ From 51f50a16645e0e77361e8adcabd6aeb3faf3bf18 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 21:29:13 +0000 Subject: [PATCH 10/12] ci: add ruff lint job and ship DMG instead of zipped .app Lint job: ruff check + format check, parallel to the build, runs on every PR. Continue-on-error for now so it surfaces style issues without blocking builds; tighten to blocking once the existing code is ruff-clean. Build job: package the py2app output as a DMG via hdiutil (built into macOS, no install needed) and upload with upload-artifact@v7's 'archive: false' option. The user gets a single XMManager.dmg to double-click and drag to Applications, instead of a zipped dist/ folder that they have to unzip just to get to the .app. Also adds a build summary in the Actions UI (app size, DMG size, main binary arch) and a status badge at the top of the README. --- .github/workflows/macos-build.yml | 94 ++++++++++++++++++++++--------- README.md | 3 + 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index c1ee595..c08d1d3 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -5,58 +5,96 @@ on: branches: [ main ] pull_request: -# Cancel in-progress runs on the same PR/branch when a new commit is -# pushed. Only on pull_request — main pushes are allowed to run to -# completion so a release build is never cut off by a follow-up commit. concurrency: group: macos-build-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# This workflow only reads the repo (checkout, pip cache key). It does -# not push back, so the token should not be granted write scope. permissions: contents: read jobs: - build-macos: + lint: + name: Lint runs-on: macos-latest - # py2app on a cold macos-latest runner is ~5–8 min; with the pip - # cache warm it drops to ~2–3 min. 20 min is a generous ceiling. - timeout-minutes: 20 - strategy: - matrix: - python: [3.14] + timeout-minutes: 5 steps: - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python }} - # Cache pip's download directory so wheels from a previous - # run are reused. Key is a hash of all dependency files so - # any change to requirements / project metadata invalidates - # the cache as expected. + python-version: '3.14' cache: 'pip' cache-dependency-path: | requirements.txt pyproject.toml setup.py + - name: Install ruff + run: python -m pip install --upgrade ruff + - name: Ruff lint + # Informational for now: surfaces style issues without blocking the build. + # Tighten to blocking once the existing code is ruff-clean. + continue-on-error: true + run: ruff check . --output-format=github + - name: Ruff format check + continue-on-error: true + run: ruff format --check . --output-format=github + build: + name: Build + needs: lint + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: | + requirements.txt + pyproject.toml + setup.py - name: Upgrade packaging tools run: python -m pip install --upgrade pip "setuptools>=61,<79" wheel py2app rumps - - name: Install runtime dependencies run: python -m pip install -r requirements.txt - - name: Install project run: python -m pip install . - - - name: Build macOS .app with py2app + - name: Build .app with py2app run: python setup.py py2app - - - name: Upload build artifact + - name: Verify .app structure + run: | + if [ ! -d "dist/XMManager.app" ]; then + echo "::error::dist/XMManager.app was not produced" + ls -laR dist/ 2>/dev/null || true + exit 1 + fi + { + echo "### Build output" + echo "" + echo "- App bundle: \`dist/XMManager.app\`" + echo "- Total size: \`$(du -sh dist/XMManager.app | cut -f1)\`" + echo "- Main binary: \`$(file dist/XMManager.app/Contents/MacOS/* | sed 's/.*: //')\`" + } >> "$GITHUB_STEP_SUMMARY" + - name: Package as DMG + run: | + # hdiutil is built into macOS, no install needed. + # UDZO = compressed read-only, the standard "drag to Applications" DMG. + hdiutil create -volname "XMManager" \ + -srcfolder dist/XMManager.app \ + -ov -format UDZO \ + XMManager.dmg + echo "DMG created:" + ls -la XMManager.dmg + { + echo "- DMG: \`XMManager.dmg\`" + echo "- DMG size: \`$(du -sh XMManager.dmg | cut -f1)\`" + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload DMG uses: actions/upload-artifact@v7 with: - name: macos-app - path: dist/ + name: XMManager + path: XMManager.dmg + archive: false + # archive: false is the v7 feature for uploading a single file + # without zipping. DMGs are a single file, so the user gets + # XMManager.dmg directly instead of XMManager.dmg.zip. diff --git a/README.md b/README.md index c8cc139..4f978a7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # XMManager + +[![macOS build](https://github.com/numycode/xmmanager/actions/workflows/macos-build.yml/badge.svg)](https://github.com/numycode/xmmanager/actions/workflows/macos-build.yml) + XMManager is an easy way to manage XMRig on your Mac. It currently allows you to toggle XMRig for easy use of the miner. # Install From c8d8184d64c96ffa1215a893c364875e7110d9ee Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Wed, 8 Jul 2026 23:50:14 +0000 Subject: [PATCH 11/12] feat: search PATH and env for xmrig; quit with notification if missing Replaces the old adjacent_to_app() helper (which assumed xmrig sat next to the .app) with find_xmrig() that checks standard macOS install locations in priority order: 1. $XMRIG_PATH environment variable 2. /opt/homebrew/bin/xmrig (Apple Silicon Homebrew) 3. /usr/local/bin/xmrig (Intel Homebrew) 4. /opt/local/bin/xmrig (MacPorts) 5. ~/bin/xmrig, ~/.local/bin/xmrig 6. Adjacent to the .app (legacy sidecar install) 7. Next to main.py (dev fallback) GUI apps launched by Launch Services on macOS inherit a stripped PATH (/usr/bin:/bin:/usr/sbin:/sbin), so 'which' isn't an option and the known locations are checked explicitly. If xmrig is not found anywhere, Main starts a one-shot rumps.Timer that fires a notification ("XMRig not found ... install with brew install xmrig or set XMRIG_PATH") and calls rumps.quit_application(). The timer is started from a run() override rather than __init__ so the NSApp run loop is already up when notification/terminate are called. xmrig_path and xmrig_command moved from module-level globals to instance attributes on Main (set from the constructor's keyword arg), so a missing binary no longer constructs a None in the command list. _start_xmrig has a defensive guard against the None case. README updated with the new install instructions and XMRIG_PATH doc. --- README.md | 12 +++-- main.py | 139 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4f978a7..71373eb 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,17 @@ XMManager is an easy way to manage XMRig on your Mac. It currently allows you to # Install ### Using prebuilt: -1. Download from releases -2. Download [XMRig](https://xmrig.com/download) and extract it to the same folder as XMManager +1. Download from releases and drag `XMManager.app` to `/Applications` +2. Install [XMRig](https://xmrig.com/download) somewhere XMManager can find it. The easiest way is `brew install xmrig`, which puts it at `/opt/homebrew/bin/xmrig` (Apple Silicon) or `/usr/local/bin/xmrig` (Intel). Other places XMManager checks automatically: + - `$XMRIG_PATH` environment variable (highest priority, useful for custom installs) + - `/opt/homebrew/bin/xmrig`, `/usr/local/bin/xmrig`, `/opt/local/bin/xmrig` (Homebrew / MacPorts) + - `~/bin/xmrig`, `~/.local/bin/xmrig` + - The same folder as `XMManager.app` (legacy "sidecar" install) - Warning - XMRig can get flagged by your antivirus due to malicious programs using it to mine without permission. XMRig is a safe program. -3. Create a [config.json](https://xmrig.com/docs/miner/config) file and put it in the same folder as the rest. (command line arguments are not yet supported) +3. Create a [config.json](https://xmrig.com/docs/miner/config) file and place it in the working directory where you launch XMManager. (Command line arguments are not yet supported.) 4. Run XMManager and have fun mining! + +If XMManager can't find xmrig at startup, it shows a notification and quits instead of staying open with a dead toggle. Set `XMRIG_PATH=/full/path/to/xmrig` in your shell profile if you installed it somewhere unusual. ### From source: I don't even remember at this point, too much debugging. I'll add CI soon so I'll update this part once I understand how I built it. diff --git a/main.py b/main.py index 5f4f98f..9936e76 100644 --- a/main.py +++ b/main.py @@ -34,22 +34,92 @@ def init(filename="xmmanager.log"): logger.info("Initialized logger") -def adjacent_to_app(name): - # TODO: get rid of that ai slop below - if getattr(sys, "frozen", False): - bundle = os.path.dirname(os.path.dirname(os.path.dirname(sys.executable))) - app_parent = os.path.dirname(bundle) - return os.path.join(app_parent, name) - return os.path.join(os.path.dirname(__file__), name) +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 = 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. + for path in ( + "/opt/homebrew/bin/xmrig", + "/usr/local/bin/xmrig", + "/opt/local/bin/xmrig", + ): + if _is_xmrig(path): + logger.info(f"Found xmrig at {path}") + return path + + # 5. User bin dirs. + home = os.path.expanduser("~") + for path in ( + os.path.join(home, "bin", "xmrig"), + os.path.join(home, ".local", "bin", "xmrig"), + ): + 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. + if getattr(sys, "frozen", False): + bundle = os.path.dirname( + os.path.dirname(os.path.dirname(sys.executable)) + ) + adjacent = os.path.join(os.path.dirname(bundle), "xmrig") + if _is_xmrig(adjacent): + logger.info(f"Found xmrig at {adjacent}") + return adjacent + + # 7. Dev fallback when running from source. + if not getattr(sys, "frozen", False): + dev_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "xmrig" + ) + if _is_xmrig(dev_path): + logger.info(f"Found xmrig at {dev_path}") + return dev_path -xmrig_path = adjacent_to_app("xmrig") -xmrig_command = [xmrig_path, "--cpu-priority=0"] + logger.error( + "xmrig not found in any standard location. Install via " + "`brew install xmrig` or set the XMRIG_PATH environment variable." + ) + return None class Main(rumps.App): - def __init__(self, *args, **kwargs): + 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 + self.xmrig_command = ( + [xmrig_path, "--cpu-priority=0"] 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 @@ -68,6 +138,15 @@ def __init__(self, *args, **kwargs): # 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 @@ -123,6 +202,32 @@ def on_quit(self, _): logger.info("Exiting...") 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) + + def _handle_missing_xmrig(self, _timer): + """One-shot callback: tell the user xmrig is missing, then exit. + + Fires from a rumps.Timer started in run() (not __init__) so the + NSApp run loop is already up. The timer is repeating under the + hood, so we stop it before doing anything to guarantee this is + actually one-shot. + """ + self._missing_quit_timer.stop() + rumps.notification( + "XMManager", + "XMRig not found", + "Install with `brew install xmrig`, or set the XMRIG_PATH " + "environment variable to the binary location. See the README.", + ) + rumps.quit_application() + def _is_running(self): proc = self.xmrig_process return proc is not None and proc.poll() is None @@ -145,11 +250,17 @@ 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 if self._is_running(): return True try: self.xmrig_process = subprocess.Popen( - xmrig_command, + self.xmrig_command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, # Put xmrig in its own process group so signals we receive @@ -283,7 +394,11 @@ def _stop_xmrig_sync(self, sigterm_timeout, sigkill_timeout): main_app = None try: init() - main_app = Main("XMManager", quit_button=None) + # 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() except Exception as e: logger.exception(f"Exception: {e}") From f8eb7a9c518eebc7688e6a49bae5b8e1009d45f7 Mon Sep 17 00:00:00 2001 From: gorkie-agent Date: Thu, 9 Jul 2026 01:06:05 +0000 Subject: [PATCH 12/12] feat: read xmrig config from ~/.xmrig.json XMRig defaults to ./config.json in the CWD, which is "/" for a Launch Services-launched menu bar app, so xmrig has never been able to find a config out of the box. Wire `--config=$HOME/.xmrig.json` into the launch command. README updated to point users at the new location and explain why it has to live outside the .app bundle. --- README.md | 2 +- main.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 71373eb..7bf5a4d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ XMManager is an easy way to manage XMRig on your Mac. It currently allows you to - `~/bin/xmrig`, `~/.local/bin/xmrig` - The same folder as `XMManager.app` (legacy "sidecar" install) - Warning - XMRig can get flagged by your antivirus due to malicious programs using it to mine without permission. XMRig is a safe program. -3. Create a [config.json](https://xmrig.com/docs/miner/config) file and place it in the working directory where you launch XMManager. (Command line arguments are not yet supported.) +3. Create a [config.json](https://xmrig.com/docs/miner/config) and save it as `~/.xmrig.json` (in your home directory). XMManager passes `--config=$HOME/.xmrig.json` to xmrig automatically, since a menu bar app can't ship a config inside the `.app` bundle. (Other command line arguments are not yet supported.) 4. Run XMManager and have fun mining! If XMManager can't find xmrig at startup, it shows a notification and quits instead of staying open with a dead toggle. Set `XMRIG_PATH=/full/path/to/xmrig` in your shell profile if you installed it somewhere unusual. diff --git a/main.py b/main.py index 9936e76..51bf9d6 100644 --- a/main.py +++ b/main.py @@ -117,8 +117,19 @@ def __init__(self, *args, xmrig_path=None, **kwargs): # 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 = ( - [xmrig_path, "--cpu-priority=0"] if xmrig_path else None + [ + xmrig_path, + "--cpu-priority=0", + f"--config={self.xmrig_config}", + ] + if xmrig_path + else None ) # Popen handle for the running xmrig, or None when stopped. # Source of truth for the toggle state.