diff --git a/.github/workflows/test_build_windows.yaml b/.github/workflows/test_build_windows.yaml index 8048bd5..71d8da1 100644 --- a/.github/workflows/test_build_windows.yaml +++ b/.github/workflows/test_build_windows.yaml @@ -1,7 +1,13 @@ name: Test Windows Build on: - workflow_dispatch: # Manual trigger + workflow_dispatch: + inputs: + debug_mode: + description: 'Enable Debug Mode' + required: false + type: boolean + default: true pull_request: # Optionally trigger on PRs jobs: @@ -32,10 +38,13 @@ jobs: (Get-Content src/version.py) -replace 'VERSION = ".*"', "VERSION = `"$testVersion`"" | Set-Content src/version.py - name: Build executable run: | - pyinstaller --clean disco_beacon.spec + # Replace the DEBUG_MODE line in the spec file + $debug_value = if ('${{ inputs.debug_mode }}' -eq 'false') { "False" } else { "True" } + (Get-Content disco_beacon_win_test.spec) -replace 'DEBUG_MODE = (?:True|False)', "DEBUG_MODE = $debug_value" | Set-Content disco_beacon_win_test.spec + pyinstaller --clean disco_beacon_win_test.spec - name: Upload test artifact uses: actions/upload-artifact@v4 with: name: DiscoBeacon_Windows_Test_${{ env.VERSION }} path: ${{ github.workspace }}/monitor-app/dist/DiscoBeacon.exe - retention-days: 5 \ No newline at end of file + retention-days: 5 diff --git a/README.md b/README.md index 1a8ade3..db9c5b4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,13 @@ Download the latest Windows or Linux build from the Releases page: 4. **Check Interval**: Set how often to check server status (in seconds) 5. **Monitor Mode**: Choose 'Both' for game and server, or 'Server Only' -Enter these settings in the app's configuration dialog to get started. You can also configure the app by editing the `settings.json` file directly. +Enter these settings in the app's configuration dialog to get started. Settings are stored in: + +- Windows: `%APPDATA%\bl4ckswordsman\Disco Beacon\settings.json` + +- Linux: `~/.config/disco-beacon/settings.json` + +On Windows, you can also enable "Run on system startup" in the settings dialog to automatically start the app when you log in. ## Web App (Svelte) diff --git a/monitor-app/disco_beacon.spec b/monitor-app/disco_beacon.spec index ccc57e4..2f80b33 100644 --- a/monitor-app/disco_beacon.spec +++ b/monitor-app/disco_beacon.spec @@ -102,4 +102,4 @@ if platform.system() == "Windows": codesign_identity=None, entitlements_file=None, icon=[ico_icon_path], - ) + ) \ No newline at end of file diff --git a/monitor-app/disco_beacon_win_test.spec b/monitor-app/disco_beacon_win_test.spec new file mode 100644 index 0000000..1933995 --- /dev/null +++ b/monitor-app/disco_beacon_win_test.spec @@ -0,0 +1,59 @@ +# -*- mode: python ; coding: utf-8 -*- + +import os + +# Toggle debug mode here +DEBUG_MODE = True # Set to True to enable console and use main.py + +block_cipher = None + +# Define the paths +icons_dir = 'icons' +ico_icon_path = os.path.join('icons', 'tower-control.ico') + +# Select main file based on debug mode +main_file = 'main.py' if DEBUG_MODE else 'windows_main.pyw' + +a = Analysis( + [main_file], # Use the selected main file + pathex=[], + binaries=[], + datas=[ + ('resources', 'resources'), + (icons_dir, 'icons'), # Include the entire icons directory + ], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='DiscoBeacon', + version_file=None, # Version info from version.py will be shown in the app + debug=DEBUG_MODE, # Set debug based on DEBUG_MODE + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=DEBUG_MODE, # Set console based on DEBUG_MODE + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[ico_icon_path], +) diff --git a/monitor-app/main.py b/monitor-app/main.py index ee164f3..449d1e2 100644 --- a/monitor-app/main.py +++ b/monitor-app/main.py @@ -6,7 +6,8 @@ from src.core.state import GameState, GameServerState from src.gui.utils.gui_init import init_gui from src.gui.utils.app_settings import AppSettings -from src.core.app_settings import settings_loader +from src.core.app_settings import settings_loader, settings_saver, handle_autorun_change +from src.gui.utils.platform_utils import is_windows gui_available = False @@ -74,6 +75,12 @@ def initialize_application(): AppSettings.set_app_metadata() logger.info("Application setup completed") + if is_windows(): + # Handle autorun based on saved setting + auto_run_enabled = settings_loader.get_setting('auto_run', False) + if not handle_autorun_change(auto_run_enabled): + settings_saver.set_setting('auto_run', False) + def main() -> None: logger.info("Application starting") diff --git a/monitor-app/settings.json b/monitor-app/settings.json deleted file mode 100644 index e99cf4a..0000000 --- a/monitor-app/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "webhook_url": "", - "api_key": "", - "steam_id": "", - "check_interval": 30, - "game_app_id": 892970, - "monitor_mode": "both" -} diff --git a/monitor-app/src/core/app_settings.py b/monitor-app/src/core/app_settings.py index 13eb21e..69fff14 100644 --- a/monitor-app/src/core/app_settings.py +++ b/monitor-app/src/core/app_settings.py @@ -1,41 +1,172 @@ import json import os +import sys +from src.gui.utils.platform_utils import is_windows +from src.core.logger import logger +from src.gui.utils.app_settings import AppSettings + +if is_windows(): + import winreg class SettingsLoader: - def __init__(self, settings_file='settings.json'): - self.settings_file = settings_file + def __init__(self): self.default_settings = { 'webhook_url': '', 'api_key': '', 'steam_id': '', 'check_interval': 10, 'game_app_id': 892970, # Valheim's App ID as default - 'monitor_mode': 'both' + 'monitor_mode': 'both', + 'auto_run': False } + self.settings_file = self._get_settings_path() self.settings = self.load_settings() + def _get_settings_path(self): + """Get the appropriate settings file path based on the platform""" + try: + if is_windows(): + base_path = os.path.join(os.getenv('APPDATA', ''), AppSettings.ORG_NAME, AppSettings.APP_NAME) + else: + config_home = os.getenv('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) + base_path = os.path.join(config_home, 'disco-beacon') + + if not os.path.exists(base_path): + old_umask = os.umask(0o077) + try: + os.makedirs(base_path, mode=0o700, exist_ok=True) + finally: + os.umask(old_umask) + + settings_path = os.path.join(base_path, 'settings.json') + return settings_path + except Exception as e: + logger.error("Failed to get settings path: {}".format(str(e))) + fallback_path = os.path.expanduser("~/.disco-beacon-settings.json") + logger.info("Using fallback settings path: {}".format(fallback_path)) + return fallback_path + def load_settings(self): - if os.path.exists(self.settings_file): - with open(self.settings_file, 'r') as f: - return json.load(f) - return self.default_settings + try: + if os.path.exists(self.settings_file): + with open(self.settings_file, 'r') as f: + loaded_settings = json.load(f) + merged_settings = self.default_settings.copy() + merged_settings.update(loaded_settings) + return merged_settings + except Exception as e: + logger.error(f"Failed to load settings: {e}") + return self.default_settings.copy() def get_setting(self, key, default=None): return self.settings.get(key, self.default_settings.get(key, default)) - class SettingsSaver: def __init__(self, settings_loader): self.settings_loader = settings_loader def save_settings(self): - with open(self.settings_loader.settings_file, 'w') as f: - json.dump(self.settings_loader.settings, f, indent=4) + temp_file = None + old_umask = None + try: + old_umask = os.umask(0o077) + settings_dir = os.path.dirname(self.settings_loader.settings_file) + + if not os.path.exists(settings_dir): + os.makedirs(settings_dir, mode=0o700) + + temp_file = f"{self.settings_loader.settings_file}.tmp" + with open(temp_file, 'w') as f: + json.dump(self.settings_loader.settings, f, indent=4) + f.flush() + os.fsync(f.fileno()) + + os.chmod(temp_file, 0o600) + os.replace(temp_file, self.settings_loader.settings_file) + os.chmod(self.settings_loader.settings_file, 0o600) + return True + + except Exception as e: + logger.error(f"Failed to save settings: {e}") + return False + + finally: + if old_umask is not None: + os.umask(old_umask) + if temp_file and os.path.exists(temp_file): + try: + os.remove(temp_file) + except OSError as e: + logger.debug(f"Failed to remove temporary file: {e}") def set_setting(self, key, value): self.settings_loader.settings[key] = value - self.save_settings() + return self.save_settings() + +def set_auto_run(app_name, app_path): + """Set up autorun for Windows""" + if not is_windows(): + return False + + try: + exe_path = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(app_path) + quoted_path = f'"{exe_path}"' + + logger.debug(f"Setting autorun registry entry: {quoted_path}") + key = r"Software\Microsoft\Windows\CurrentVersion\Run" + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key) as reg_key: + winreg.SetValueEx(reg_key, app_name, 0, winreg.REG_SZ, quoted_path) + logger.debug("Registry entry created successfully") + return True + except Exception as e: + logger.error(f"Failed to set autorun registry: {e}") + return False + +def remove_auto_run(app_name): + """Remove autorun entry for Windows""" + if not is_windows(): + return True + + try: + key = r"Software\Microsoft\Windows\CurrentVersion\Run" + logger.debug(f"Removing autorun registry entry for {app_name}") + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as reg_key: + winreg.DeleteValue(reg_key, app_name) + logger.debug("Registry entry removed successfully") + return True + except FileNotFoundError: + logger.debug(f"No autorun registry entry found for {app_name}") + return True + except Exception as e: + logger.error(f"Failed to remove autorun registry: {e}") + return False + +def handle_autorun_change(enabled: bool): + """Handle changes to autorun setting""" + if not is_windows(): + logger.debug("Autorun change requested but system is not Windows") + return True + + app_name = AppSettings.APP_NAME + app_path = os.path.abspath(sys.argv[0]) + if enabled: + logger.info(f"Enabling autorun for {app_name}") + success = set_auto_run(app_name, app_path) + if success: + logger.info("Autorun successfully enabled") + else: + logger.error("Failed to enable autorun") + return success + else: + logger.info(f"Disabling autorun for {app_name}") + success = remove_auto_run(app_name) + if success: + logger.info("Autorun successfully disabled") + else: + logger.error("Failed to disable autorun") + return success +# Initialize singleton instances settings_loader = SettingsLoader() settings_saver = SettingsSaver(settings_loader) diff --git a/monitor-app/src/gui/settings_dialog.py b/monitor-app/src/gui/settings_dialog.py index 18e604a..b067ee8 100644 --- a/monitor-app/src/gui/settings_dialog.py +++ b/monitor-app/src/gui/settings_dialog.py @@ -1,10 +1,10 @@ from PySide6.QtWidgets import (QDialog, QVBoxLayout, QFormLayout, QLineEdit, - QSpinBox, QPushButton, QComboBox, QLabel) + QSpinBox, QPushButton, QComboBox, QLabel, QCheckBox) from PySide6.QtCore import Signal, Qt from src.version import __version__ -from src.core.app_settings import settings_loader, settings_saver +from src.core.app_settings import settings_loader, settings_saver, handle_autorun_change from src.core import constants -from src.gui.utils.platform_utils import is_windows_11 +from src.gui.utils.platform_utils import is_windows_11, is_windows from src.gui.utils.mica_utils import apply_mica_to_window class SettingsDialog(QDialog): @@ -50,6 +50,14 @@ def __init__(self, parent=None): self.monitor_mode.setCurrentText(settings_loader.get_setting('monitor_mode', 'Both').title()) form_layout.addRow("Monitor Mode:", self.monitor_mode) + self.auto_run_checkbox = QCheckBox("Run on system startup") + if is_windows(): + self.auto_run_checkbox.setChecked(settings_loader.get_setting('auto_run', False)) + else: + self.auto_run_checkbox.setEnabled(False) + self.auto_run_checkbox.setToolTip("Auto-run is only supported on Windows") + form_layout.addRow(self.auto_run_checkbox) + self.layout.addLayout(form_layout) save_button = QPushButton("Save") @@ -68,10 +76,39 @@ def __init__(self, parent=None): self.layout.addWidget(self.build_version_label) def save_settings(self): + # Save all non-autorun settings first settings_saver.set_setting('webhook_url', self.webhook_url.text()) settings_saver.set_setting('api_key', self.api_key.text()) settings_saver.set_setting('steam_id', self.steam_id.text()) settings_saver.set_setting('check_interval', self.check_interval.value()) settings_saver.set_setting('game_app_id', [k for k, v in constants.SUPPORTED_GAMES.items() if v == self.game_selector.currentText()][0]) settings_saver.set_setting('monitor_mode', self.monitor_mode.currentText().lower()) + + # Handle autorun setting separately to apply changes immediately + autorun_enabled = self.auto_run_checkbox.isChecked() + + # Handle autorun changes + autorun_enabled = self.auto_run_checkbox.isChecked() + registry_updated = handle_autorun_change(autorun_enabled) + settings_saved = settings_saver.set_setting('auto_run', registry_updated and autorun_enabled) + + if is_windows() and not registry_updated: + self.auto_run_checkbox.setChecked(False) + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( + self, + "Settings Error", + "Failed to save autorun settings. Make sure you have the necessary permissions." + ) + return + + if not settings_saved: + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( + self, + "Settings Error", + "Failed to save settings. Make sure you have write permissions in your config directory." + ) + return + self.accept() diff --git a/monitor-app/src/gui/utils/platform_utils.py b/monitor-app/src/gui/utils/platform_utils.py index bf8b5ce..8b2aaa4 100644 --- a/monitor-app/src/gui/utils/platform_utils.py +++ b/monitor-app/src/gui/utils/platform_utils.py @@ -8,3 +8,7 @@ def is_linux(): def is_windows_11(): """Check if the current system is Windows 11.""" return QOperatingSystemVersion.current() >= QOperatingSystemVersion.Windows11 + +def is_windows(): + """Check if the current platform is Windows.""" + return platform.system() == 'Windows'