From 1166170faeb9200fbef55d21266a8b0b2ad0cce3 Mon Sep 17 00:00:00 2001 From: Crocchetto <162356915+crocchetto@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:49:58 +0100 Subject: [PATCH] Added macOS support (#968) Co-authored-by: DevilXD <4180725+DevilXD@users.noreply.github.com> --- .github/workflows/ci.yml | 53 +++++++++++++++++++++++ .gitignore | 2 + README.md | 8 ++++ build.spec | 12 +++++- gui.py | 91 ++++++++++++++++++++++++++++++---------- utils.py | 2 +- 6 files changed, 143 insertions(+), 25 deletions(-) mode change 100755 => 100644 build.spec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d57992..6735ca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,58 @@ jobs: name: Twitch.Drops.Miner.Windows path: Twitch.Drops.Miner.Windows.zip + macos: + name: macOS + runs-on: macos-latest + needs: + - validate + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up variables + id: vars + run: | + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Append git revision to project version + run: | + sed -i '' -E "s/^(__version__\s*=\s*\"[^\"]+)/\1.${{steps.vars.outputs.sha_short}}/" version.py + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{env.PYTHON_VERSION}} + + - name: Install project dependencies + run: | + python3 -m pip install wheel + python3 -m pip install -r requirements.txt + + - name: Install PyInstaller + run: | + python3 -m pip install pyinstaller + + - name: Create portable executable + run: | + pyinstaller build.spec --noconfirm + + - name: Create release folder and Zip + run: | + FOLDER="Twitch Drops Miner" + mkdir -p "$FOLDER" + cp -r "dist/Twitch Drops Miner (by DevilXD).app" "$FOLDER/" + cp manual.txt "$FOLDER/" + zip -r Twitch.Drops.Miner.MacOS.zip "$FOLDER" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + if-no-files-found: error + name: Twitch.Drops.Miner.MacOS + path: Twitch.Drops.Miner.MacOS.zip + linux-pyinstaller: name: Linux (PyInstaller) (${{matrix.arch}}) strategy: @@ -256,6 +308,7 @@ jobs: - windows - linux-pyinstaller - linux-appimage + - macos runs-on: ubuntu-latest permissions: contents: write diff --git a/.gitignore b/.gitignore index 90b9f7e..0dc31bc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ settings.json /appimage-build /*.AppImage /*.AppImage.zsync +.DS_Store +*.app diff --git a/README.md b/README.md index a973eb0..5699ba4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ Every several seconds, the application pretends to watch a particular stream by - The size of the Linux app is significantly larger than the Windows app due to the inclusion of the `gtk3` library (and its dependencies), which is required for proper system tray/notifications support. - As an alternative to the native Linux app, you can run the Windows app via [Wine](https://www.winehq.org/) instead. It works really well! +### Notes about the macOS build: + +- The macOS version is packaged using PyInstaller into a standalone `.app` bundle, distributed as a ZIP archive. +- Since this application is not signed with a paid Apple Developer Certificate, **macOS Gatekeeper will block it** on the first run (saying it "The application is damaged and can't be opened"). + - **To fix this**: Either open the Terminal in the folder the app is in (or navigating with `cd path/to/folder`) and enter `xattr -cr Twitch Drops Miner (by DevilXD).app` or just type `xattr -cr ` (make sure to put a space at the end), drag and drop the `Twitch Drops Miner (by DevilXD).app` file into the terminal window (this will auto-fill the path) and enter +- Persistent files (like `cookies.jar`, `settings.json`, `lock.file` and the `cache` folder) are stored inside the application bundle in `Twitch Drops Miner (by DevilXD).app/Contents/MacOS` (to access them Right-click the application and select `Show Package Contents`) + ### Advanced Usage: If you'd be interested in running the latest master from source or building your own executable, see the wiki page explaining how to do so: https://github.com/DevilXD/TwitchDropsMiner/wiki/Setting-up-the-environment,-building-and-running @@ -135,6 +142,7 @@ placed past the period character at the end. @guihkx - For the CI script, CI maintenance, and everything related to Linux builds. @kWAYTV - For the implementation of the dark mode theme. +@crocchetto - For the macOS port. @Bamboozul - For the entirety of the Arabic (العربية) translation. @Suz1e - For the entirety of the Chinese (简体中文) translation and revisions. diff --git a/build.spec b/build.spec old mode 100755 new mode 100644 index 1a98ea2..0bf9d07 --- a/build.spec +++ b/build.spec @@ -19,7 +19,7 @@ if TYPE_CHECKING: from PyInstaller.building.splash import Splash from PyInstaller.building.build_main import Analysis from PyInstaller.building.datastruct import _TOCTuple - from PyInstaller.building.api import PYZ, EXE, COLLECT + from PyInstaller.building.api import PYZ, EXE, COLLECT, BUNDLE PYZTypeCOLLECT: TypeAlias = "abc.Iterable[_TOCTuple] | PYZ" @@ -142,3 +142,13 @@ if one_dir: upx=upx, name=app_name, ) + +# macOS bundle support +if sys.platform == "darwin": + source = coll if one_dir else exe + app = BUNDLE( + source, + name=f'{app_name}.app', + icon="icons/pickaxe.ico", + bundle_identifier='com.twitchdrops.miner', + ) diff --git a/gui.py b/gui.py index 67a43e5..b90b878 100644 --- a/gui.py +++ b/gui.py @@ -3,9 +3,11 @@ from __future__ import annotations import os import re import sys +import shlex import ctypes import asyncio import logging +import plistlib import tkinter as tk from pathlib import Path from collections import abc @@ -28,6 +30,9 @@ if sys.platform == "win32": import win32con import win32gui +if sys.platform == "darwin": + import AppKit + from translate import _ from cache import ImageCache from exceptions import MinerException, ExitRequest @@ -1087,7 +1092,10 @@ class TrayIcon: } self._icon_state: str = "pickaxe" self._button = ttk.Button(master, command=self.minimize, text=_("gui", "tray", "minimize")) - self._button.grid(column=0, row=0, sticky="ne") + + # Hides Tray button for macOS + if sys.platform != "darwin": + self._button.grid(column=0, row=0, sticky="ne") def __del__(self) -> None: self.stop() @@ -1153,6 +1161,8 @@ class TrayIcon: self._manager.close() def minimize(self): + if sys.platform == "darwin": + return if self.icon is None: self._start() else: @@ -1635,22 +1645,23 @@ class SettingsPanel: ttk.Checkbutton( checkboxes_frame, variable=self._vars["autostart"], command=self.update_autostart ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "tray") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - ttk.Checkbutton( - checkboxes_frame, variable=self._vars["tray"], command=self.update_autostart - ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "tray_notifications") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - ttk.Checkbutton( - checkboxes_frame, - variable=self._vars["tray_notifications"], - command=lambda: setattr( - self._settings, "tray_notifications", bool(self._vars["tray_notifications"].get()) - ), - ).grid(column=1, row=irow, sticky="w") + if sys.platform != "darwin": + ttk.Label( + checkboxes_frame, text=_("gui", "settings", "general", "tray") + ).grid(column=0, row=(irow := irow + 1), sticky="e") + ttk.Checkbutton( + checkboxes_frame, variable=self._vars["tray"], command=self.update_autostart + ).grid(column=1, row=irow, sticky="w") + ttk.Label( + checkboxes_frame, text=_("gui", "settings", "general", "tray_notifications") + ).grid(column=0, row=(irow := irow + 1), sticky="e") + ttk.Checkbutton( + checkboxes_frame, + variable=self._vars["tray_notifications"], + command=lambda: setattr( + self._settings, "tray_notifications", bool(self._vars["tray_notifications"].get()) + ), + ).grid(column=1, row=irow, sticky="w") ttk.Label( checkboxes_frame, text=_("gui", "settings", "general", "dark_mode") ).grid(column=0, row=(irow := irow + 1), sticky="e") @@ -1757,7 +1768,7 @@ class SettingsPanel: ttk.Button( # Move to top priority_frame, width=2, - text="⭱", + text="⇈", style="Arrow.TButton", command=partial(self.priority_move, MAX_INT), ).grid(column=1, row=1, sticky="nsew") @@ -1765,7 +1776,7 @@ class SettingsPanel: ttk.Button( # Move up priority_frame, width=2, - text="🠙", + text="↑", style="Arrow.TButton", command=partial(self.priority_move, 1), ).grid(column=1, row=2, sticky="nsew") @@ -1773,7 +1784,7 @@ class SettingsPanel: ttk.Button( # Move down priority_frame, width=2, - text="🠛", + text="↓", style="Arrow.TButton", command=partial(self.priority_move, -1), ).grid(column=1, row=3, sticky="nsew") @@ -1781,7 +1792,7 @@ class SettingsPanel: ttk.Button( # Move to bottom priority_frame, width=2, - text="⭳", + text="⇊", style="Arrow.TButton", command=partial(self.priority_move, -MAX_INT), ).grid(column=1, row=4, sticky="nsew") @@ -1867,6 +1878,9 @@ class SettingsPanel: autostart_folder = config_autostart return autostart_folder / f"{self.AUTOSTART_NAME}.desktop" + def _get_mac_autostart_filepath(self) -> Path: + return Path(Path.home(), f"Library/LaunchAgents/com.devilxd.{self.AUTOSTART_NAME.lower()}.plist") + def _query_autostart(self) -> bool: if sys.platform == "win32": with RegistryKey(self.AUTOSTART_KEY, read_only=True) as key: @@ -1886,6 +1900,12 @@ class SettingsPanel: with autostart_file.open('r', encoding="utf8") as file: # TODO: Consider deleting the old file to avoid autostart errors return self._get_self_path() in file.read() + elif sys.platform == "darwin": + plist_file = self._get_mac_autostart_filepath() + if not plist_file.exists(): + return False + with plist_file.open('r', encoding="utf8") as file: + return str(SELF_PATH.resolve()) in file.read() def update_autostart(self) -> None: enabled = bool(self._vars["autostart"].get()) @@ -1917,6 +1937,21 @@ class SettingsPanel: file.write(file_contents) else: autostart_file.unlink(missing_ok=True) + elif sys.platform == "darwin": + plist_file = self._get_mac_autostart_filepath() + + if enabled: + command_parts = shlex.split(self._get_autostart_path()) + plist_data = { + "Label": f"com.devilxd.{self.AUTOSTART_NAME.lower()}", + "ProgramArguments": command_parts, + "RunAtLoad": True, + } + plist_file.parent.mkdir(parents=True, exist_ok=True) + with plist_file.open("wb") as file: + plistlib.dump(plist_data, file) + else: + plist_file.unlink(missing_ok=True) def update_excluded_choices(self) -> None: self._exclude_entry.config( @@ -2250,7 +2285,7 @@ class GUIManager: self._orig_theme_name = '' self.apply_theme(self._twitch.settings.dark_mode) # stay hidden in tray if needed, otherwise show the window when everything's ready - if self._twitch.settings.tray: + if self._twitch.settings.tray and sys.platform != "darwin": # NOTE: this starts the tray icon thread self._root.after_idle(self.tray.minimize) else: @@ -2422,7 +2457,7 @@ class GUIManager: # Palette if dark: # Switch to a configurable ttk theme for better color control - if self._style.theme_use() != "clam": + if sys.platform != "darwin" and self._style.theme_use() != "clam": self._style.theme_use("clam") bg = "#1e1e1e" fg = "#e6e6e6" @@ -2452,6 +2487,16 @@ class GUIManager: muted = "#404040" accent = "#0a84ff" + + # Setting theme for macOS + if sys.platform == "darwin": + app = AppKit.NSApplication.sharedApplication() + if dark: + appearance = AppKit.NSAppearance.appearanceNamed_(AppKit.NSAppearanceNameDarkAqua) + else: + appearance = AppKit.NSAppearance.appearanceNamed_(AppKit.NSAppearanceNameAqua) + app.setAppearance_(appearance) + s = self._style # Fonts default_font = nametofont("TkDefaultFont") diff --git a/utils.py b/utils.py index 5fc9440..9d5de5c 100644 --- a/utils.py +++ b/utils.py @@ -82,7 +82,7 @@ def lock_file(path: Path) -> tuple[bool, io.TextIOWrapper]: except Exception: return False, file return True, file - if sys.platform == "linux": + if sys.platform in ("linux", "darwin"): import fcntl try: fcntl.lockf(file, fcntl.LOCK_EX | fcntl.LOCK_NB)