diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce4399f..6641c2e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,18 @@ -name: Build Windows +name: Build on: pull_request: - branches: [ main ] + branches: [ 'main' ] workflow_dispatch: jobs: build: - runs-on: windows-latest + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] steps: - uses: actions/checkout@v4 @@ -18,23 +23,89 @@ jobs: python-version: '3.12' cache: 'pip' - - name: Install dependencies + - name: Install System Dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 libegl1 + + # Ensure executable permission for bundled binary + chmod +x resources/bin/aria2c || true + ls -l resources/bin/aria2c || echo "Aria2c not found in resources/bin" + + - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pyinstaller - name: Setup UPX + if: runner.os == 'Windows' uses: crazy-max/ghaction-upx@v3 with: version: latest + install-only: true - name: Build with PyInstaller run: | pyinstaller EmuMan.spec - - name: Upload Artifact + - name: Upload Artifact (Windows) + if: runner.os == 'Windows' uses: actions/upload-artifact@v4 with: name: EmuMan-Windows path: dist/EmuMan.exe + + - name: Upload Artifact (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: EmuMan-Linux + path: dist/EmuMan + + release: + name: Release + needs: build + runs-on: ubuntu-latest + if: github.ref_name == 'main' + + steps: + - name: Download Windows Artifact + uses: actions/download-artifact@v4 + with: + name: EmuMan-Windows + path: release_dist + + - name: Download Linux Artifact + uses: actions/download-artifact@v4 + with: + name: EmuMan-Linux + path: release_dist + + - name: Rename Artifacts + run: | + mv release_dist/EmuMan.exe release_dist/EmuMan-Windows.exe + mv release_dist/EmuMan release_dist/EmuMan-Linux + chmod +x release_dist/EmuMan-Linux + + - name: Get Version + run: | + VERSION=$(grep 'CURRENT_VERSION =' app/config.py | cut -d '"' -f 2) + echo "Detected version: $VERSION" + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Create Release + uses: softprops/action-gh-release@v2.5.0 + with: + tag_name: ${{ env.VERSION }} + prerelease: false + draft: false + generate_release_notes: false + fail_on_unmatched_files: false + make_latest: true + files: | + release_dist/EmuMan-Windows.exe + release_dist/EmuMan-Linux + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 660a063..a6f9882 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ api_cache.json cache/ backups/ downloads/ +upx/ # IDEs .idea/ @@ -39,7 +40,3 @@ downloads/ .cursor/ *.swp *.swo - - -# Binaries -upx.exe diff --git a/EmuMan.spec b/EmuMan.spec index 3965208..e63da36 100644 --- a/EmuMan.spec +++ b/EmuMan.spec @@ -1,4 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- +import shutil +import sys +import os block_cipher = None @@ -19,10 +22,19 @@ a = Analysis( ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +# Define Aria2 Path based on OS +aria2_filename = 'aria2c.exe' if sys.platform == 'win32' else 'aria2c' +aria2_path = os.path.join('resources', 'bin', aria2_filename) +aria2_binary = [] + +if os.path.exists(aria2_path): + # Bundle it to the root of the frozen app (sys._MEIPASS/aria2c) + aria2_binary = [(aria2_filename, aria2_path, 'BINARY')] + exe = EXE( pyz, a.scripts, - a.binaries, + a.binaries + aria2_binary, a.zipfiles, a.datas, [], @@ -30,8 +42,8 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, - upx_exclude=[], + upx=(sys.platform == 'win32'), + upx_exclude=['_uuid.pyd', 'vcruntime140.dll', 'python3.dll', 'python312.dll'], runtime_tmpdir=None, console=False, disable_windowed_traceback=False, diff --git a/README.md b/README.md index 4bf8318..7794fd1 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,18 @@ **EmuMan** (Emulator Manager) is a helper utility specifically designed for the Eden Emulator. It addresses common pain points such as frequent emulator updates, complex firmware management, and difficult save data backups by providing a one-stop maintenance solution for players. > [!TIP] -> Whether you are a player seeking stability or a developer who enjoys testing the latest features, EmuMan helps you effortlessly switch between different emulator branches while keeping your saves and settings safe. +> **Stay Informed & Secure**: EmuMan puts the latest Eden changelogs front and center, so you always know what's new. Whether you're chasing the latest features or sticking to stability, switch branches effortlessly while keeping your saves safe. --- ## ✨ Core Features - 🚀 **Multi-Version Management**: Automatic downloading, installation, and quick switching between Master (Stable) and Nightly (Dev) branches. -- 📦 **Firmware & Keys Management**: Automatically sync the latest firmware or install local files. One-click scanning and importing for `prod.keys` and `title.keys`. -- 🛡️ **Safe Backup**: Integrated "Save Manager" with multi-point backup and restore functionality—never worry about losing saves during updates again. +- 📦 **Firmware & Keys Management**: Automatically sync the latest firmware or install local files. One-click scanning and importing for `*.keys`. +- 🛡️ **Safe Backup**: Integrated "Save Manager" with multi-point backup and restore functionality—never worry about losing saves during updates. - 🛠️ **Toolbox**: Useful utilities including log exporting and Mod (LayeredFS) toggle management. - 🌍 **Multi-Language Support**: Native support for English, Chinese (Simplified/Traditional), Japanese, Korean, and more. -- 🎨 **Modern UI**: A high-quality native interface built with PySide6, featuring smooth Light/Dark mode transitions. +- 🎨 **Modern UI**: A high-quality native interface built with [PySide6-Fluent-Widgets](https://github.com/zhiyiYo/PyQt-Fluent-Widgets/tree/PySide6), featuring smooth Light/Dark mode transitions. --- @@ -35,43 +35,42 @@ ### Option 1: Run from Source (Recommended for Developers) -1. **Clone the Repo**: +1. **Prerequisites (Linux Only)**: ```bash - git clone https://github.com/your-username/EmuMan.git + # Debian/Ubuntu + sudo apt install aria2 python3-venv python3-pip + # Arch Linux + sudo pacman -S aria2 python-pip + ``` + +2. **Clone the Repo**: + ```bash + git clone https://github.com/pflyly/EmuMan.git cd EmuMan ``` -2. **Create and Activate Virtual Environment**: +3. **Create and Activate Virtual Environment**: ```bash python -m venv .venv # Windows .venv\Scripts\activate + # Linux/macOS + source .venv/bin/activate ``` -3. **Install Dependencies**: +4. **Install Dependencies**: ```bash pip install -r requirements.txt ``` -4. **Launch Application**: +5. **Launch Application**: ```bash python main.py ``` ### Option 2: Run Pre-compiled Version (Coming Soon) -Please visit the [Releases](https://github.com/your-username/EmuMan/releases) page to download the latest `.exe` installer. - ---- - -## 📅 Roadmap - -- [x] Multi-language UI support -- [x] Automatic sync for multiple emulator versions -- [x] One-click firmware/keys installation -- [ ] More intelligent Mod management (In progress...) -- [ ] Cloud save synchronization (Planned) -- [ ] Game library management (In design) +Please visit the [Releases](https://github.com/pflyly/EmuMan/releases) page to download the latest `.exe` installer. --- diff --git a/app/core/file_processor.py b/app/core/file_processor.py index fede61f..7b2ecdf 100644 --- a/app/core/file_processor.py +++ b/app/core/file_processor.py @@ -39,6 +39,10 @@ def fix_executable_permission(path): """Apply chmod +x to a specific file on Linux/Unix systems.""" if sys.platform == "win32": return try: + # Idempotency check: if already executable, skip + if os.access(path, os.X_OK): + return True + logger.info(f"Linux: Applying executable permission to {path}") st = os.stat(path) os.chmod(path, st.st_mode | stat.S_IEXEC) diff --git a/app/core/firmware_manager.py b/app/core/firmware_manager.py index 208546e..2ac53fb 100644 --- a/app/core/firmware_manager.py +++ b/app/core/firmware_manager.py @@ -130,6 +130,8 @@ def list_local_firmware(): return results + + @staticmethod def get_firmware_version(eden_exe_path=None): """ @@ -141,20 +143,13 @@ def get_firmware_version(eden_exe_path=None): log_time = 0 log_paths = [] - if eden_exe_path and os.path.exists(eden_exe_path): - exe_dir = Path(eden_exe_path).parent - portable_log = exe_dir / "user" / "log" / "eden_log.txt" - log_paths.append(portable_log) + user_dir = FirmwareManager.get_user_data_path(eden_exe_path) - if sys.platform == "win32": - system_log = Path.home() / "AppData" / "Roaming" / "eden" / "log" / "eden_log.txt" - else: - xdg_data_home = os.getenv("XDG_DATA_HOME") - if xdg_data_home: - system_log = Path(xdg_data_home) / "eden" / "log" / "eden_log.txt" - else: - system_log = Path.home() / ".local" / "share" / "eden" / "log" / "eden_log.txt" + # Determine portable log path if applicable (it resides in user/log) + # However _get_base_user_dir returns the 'user' or equivalent root. + # Log is at /log/eden_log.txt + system_log = user_dir / "log" / "eden_log.txt" log_paths.append(system_log) for log_path in log_paths: @@ -449,24 +444,8 @@ def get_nand_path(eden_exe_path=None): 2. System (Win): %APPDATA%/eden/nand/system/Contents/registered/ 3. System (Linux): ~/.local/share/eden/nand/system/Contents/registered/ """ - if eden_exe_path and os.path.exists(eden_exe_path): - path_obj = Path(eden_exe_path) - # If input is directory (from settings), use it as exe_dir; if file (exe), use parent - exe_dir = path_obj if path_obj.is_dir() else path_obj.parent - - # Check for Portable Mode (check 'user' folder) - if (exe_dir / "user").exists(): - return exe_dir / "user" / "nand" / "system" / "Contents" / "registered" - - # System Mode Fallback - if sys.platform == "win32": - return Path.home() / "AppData" / "Roaming" / "eden" / "nand" / "system" / "Contents" / "registered" - else: - xdg_data_home = os.getenv("XDG_DATA_HOME") - if xdg_data_home: - return Path(xdg_data_home) / "eden" / "nand" / "system" / "Contents" / "registered" - else: - return Path.home() / ".local" / "share" / "eden" / "nand" / "system" / "Contents" / "registered" + user_dir = FirmwareManager.get_user_data_path(eden_exe_path) + return user_dir / "nand" / "system" / "Contents" / "registered" @staticmethod def get_user_data_path(eden_exe_path=None): @@ -486,11 +465,15 @@ def get_user_data_path(eden_exe_path=None): if sys.platform == "win32": return Path.home() / "AppData" / "Roaming" / "eden" else: - xdg_data_home = os.getenv("XDG_DATA_HOME") - if xdg_data_home: - return Path(xdg_data_home) / "eden" - else: - return Path.home() / ".local" / "share" / "eden" + default_linux = Path.home() / ".local" / "share" / "eden" + if default_linux.exists(): + return default_linux + + xdg = os.getenv("XDG_DATA_HOME") + if xdg: + return Path(xdg) / "eden" + + return default_linux @staticmethod def install_firmware(zip_path, eden_exe_path=None, progress_callback=None, cancel_check=None, version_tag=None): diff --git a/app/core/keys_manager.py b/app/core/keys_manager.py index 36f2587..e0fcdde 100644 --- a/app/core/keys_manager.py +++ b/app/core/keys_manager.py @@ -69,11 +69,10 @@ def auto_detect_keys(): candidates = [] roaming = Path(os.getenv('APPDATA')) if os.name == 'nt' else Path.home() / ".config" - # Common paths for Yuzu/Ryujinx + # Eden paths search_paths = [ - roaming / "yuzu" / "keys", - roaming / "Ryujinx" / "system", - roaming / "suyu" / "keys", + roaming / "eden" / "keys", + Path.home() / ".local" / "share" / "eden" / "keys", ] found_files = [] diff --git a/app/ui/about_interface.py b/app/ui/about_interface.py index 83040cf..07342d4 100644 --- a/app/ui/about_interface.py +++ b/app/ui/about_interface.py @@ -92,7 +92,7 @@ def initCredits(self): components = [ ("Eden Emulator", "https://git.eden-emu.dev/eden-emu/eden", "Core Switch Emulator"), ("Aria2", "https://github.com/aria2/aria2", "High speed download utility"), - ("QFluentWidgets", "https://qfluentwidgets.com/", "Modern UI Components"), + ("PyQt-Fluent-Widgets", "https://github.com/zhiyiYo/PyQt-Fluent-Widgets/tree/PySide6", "Modern UI Components"), ("NX Firmware", "https://github.com/THZoria/NX_Firmware", "Firmware Source"), ] diff --git a/app/ui/home_interface.py b/app/ui/home_interface.py index 95611d1..de05768 100644 --- a/app/ui/home_interface.py +++ b/app/ui/home_interface.py @@ -23,7 +23,7 @@ from app.core.app_updater import AppUpdater from app.core.firmware_manager import FirmwareManager from app.ui.components.channel_card import ChannelCard -from app.utils.path_utils import get_resource_path +from app.utils.path_utils import get_resource_path, open_directory class DownloadSelectionDialog(MessageBoxBase): def __init__(self, parent, title, items, best_index=0): @@ -318,18 +318,25 @@ def open_user_data_folder(self): eden_exe_path = self._get_current_eden_exe() data_path = FirmwareManager.get_user_data_path(eden_exe_path) + # Ensure path is absolute for safety + if data_path: + data_path = Path(data_path).resolve() + if data_path and data_path.exists(): - QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_path))) + success, msg = open_directory(str(data_path)) + if not success: + InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) else: # Try to create it if system default - if not eden_exe_path: + if not eden_exe_path and data_path: # System default assumption - if not data_path.exists(): - try: - data_path.mkdir(parents=True, exist_ok=True) - QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_path))) - return - except: pass + try: + data_path.mkdir(parents=True, exist_ok=True) + success, msg = open_directory(str(data_path)) + if not success: + InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) + return + except: pass InfoBar.warning( title=self.lang.get("user_folder_not_found", "Folder Not Found"), @@ -340,20 +347,33 @@ def open_user_data_folder(self): def open_eden_folder(self): """Open the configured Eden Emulator Download Folder""" + base_path = "" if os.path.exists("config.json"): try: with open("config.json", 'r', encoding='utf-8') as f: base_path = json.load(f).get("path", "") - - if base_path and os.path.exists(base_path): - QDesktopServices.openUrl(QUrl.fromLocalFile(base_path)) - return except Exception: pass - # Fallback + # Fallback to default if not configured + if not base_path: + base_path = os.path.join(os.getcwd(), "downloads", "eden") + + if base_path: + # Create if missing (user expectation: "Open MY folder") + if not os.path.exists(base_path): + try: os.makedirs(base_path, exist_ok=True) + except: pass + + if os.path.exists(base_path): + success, msg = open_directory(base_path) + if not success: + InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) + return + + # Fallback error (Should technically be unreachable if creation works) InfoBar.warning( title=self.lang.get("file_not_found", "File Not Found"), - content="Eden Emulator path not configured.", + content="Eden Emulator path invalid.", parent=self ) diff --git a/app/ui/tools_interface.py b/app/ui/tools_interface.py index 9dcae85..4746de6 100644 --- a/app/ui/tools_interface.py +++ b/app/ui/tools_interface.py @@ -24,6 +24,7 @@ from app.core.keys_manager import KeysManager from app.core.mod_manager import ModManager from app.core.firmware_manager import FirmwareManager, FirmwareInstallWorker, FirmwareUpdateCheckWorker +from app.utils.path_utils import open_directory class RestoreDialog(MessageBoxBase): @@ -238,7 +239,9 @@ def toggle_selected(self): def open_folder(self): ModManager.open_mod_folder(self.get_eden_exe()) if path := ModManager.get_load_dir(self.get_eden_exe()): - QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + success, msg = open_directory(str(path)) + if not success: + InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) def show_context_menu(self, pos): item = self.tree.itemAt(pos) @@ -787,7 +790,9 @@ def on_mod_manager_clicked(self): def open_mod_folder(self): path = ModManager.get_load_dir(self._get_eden_exe()) if path and path.exists(): - QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + success, msg = open_directory(str(path)) + if not success: + InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) else: InfoBar.warning(title=self.lang.get("not_found", "Not Found"), content=str(path), parent=self) @@ -907,12 +912,14 @@ def on_firmware_manager_clicked(self): def open_firmware_folder(self): path = FirmwareManager.get_firmware_path_config() if os.path.exists(path): - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + success, msg = open_directory(path) + if not success: InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) else: # Create if not exists? try: os.makedirs(path, exist_ok=True) - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + success, msg = open_directory(path) + if not success: InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) except: InfoBar.warning(title=self.lang.get("not_found", "Not Found"), content=str(path), parent=self) @@ -964,7 +971,8 @@ def on_backup_clicked(self): def open_backup_folder(self): root = BackupManager.get_backup_root() if root.exists(): - QDesktopServices.openUrl(QUrl.fromLocalFile(str(root))) + success, msg = open_directory(str(root)) + if not success: InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) else: InfoBar.warning(title=self.lang.get("not_found", "Not Found"), content=str(root), parent=self) @@ -1060,7 +1068,8 @@ def open_keys_folder(self): except: pass if path.exists(): - QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + success, msg = open_directory(str(path)) + if not success: InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) else: InfoBar.warning(self.lang.get("error", "Error"), self.lang.get("create_folder_fail", "Could not create folder: {}").format(path), parent=self) def get_eden_log_dir(self): @@ -1075,7 +1084,8 @@ def open_log_folder(self): if not log_dir.exists(): try: log_dir.mkdir(parents=True, exist_ok=True) except: pass - QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_dir))) + success, msg = open_directory(str(log_dir)) + if not success: InfoBar.error(title=self.lang.get("error", "Error"), content=msg, parent=self) def export_logs(self): log_dir = self.get_eden_log_dir() diff --git a/app/utils/downloader.py b/app/utils/downloader.py index a2ecded..a713f56 100644 --- a/app/utils/downloader.py +++ b/app/utils/downloader.py @@ -61,29 +61,50 @@ class Downloader: @staticmethod def get_aria2_executable(): """Get path to aria2c executable, handling both dev and frozen environments.""" - # 1. Check system path - if shutil.which("aria2c"): - return "aria2c" + exe_name = "aria2c.exe" if sys.platform == "win32" else "aria2c" - # 2. Check bundled resources - # If frozen (PyInstaller), resources are usually extracted to sys._MEIPASS + # 1. Check bundled resources (Prioritize bundled for consistency) if getattr(sys, 'frozen', False): + # PyInstaller One-file: sys._MEIPASS base_path = Path(sys._MEIPASS) + + if sys.platform == "win32": + candidates = [ + base_path / exe_name, + base_path / "resources" / "bin" / exe_name + ] + else: + # Linux: Only check root (where we bundled system aria2c) + candidates = [ + base_path / exe_name + ] else: + # Dev environment base_path = Path.cwd() - - exe_name = "aria2c.exe" if sys.platform == "win32" else "aria2c" - bundled = base_path / "resources" / "bin" / exe_name - - if bundled.exists(): - # Linux: Ensure executable permission - if sys.platform != "win32" and not os.access(bundled, os.X_OK): - try: - os.chmod(bundled, 0o755) - logger.info(f"Granted executable permission to {bundled}") - except Exception as e: - logger.warning(f"Failed to chmod {bundled}: {e}") - return str(bundled) + if sys.platform == "win32": + candidates = [ + base_path / "resources" / "bin" / exe_name + ] + else: + # Linux Dev: Check resources/bin as well + candidates = [ + base_path / "resources" / "bin" / exe_name + ] + + for bundled in candidates: + if bundled.exists(): + # Linux: Ensure executable permission + if sys.platform != "win32" and not os.access(bundled, os.X_OK): + try: + os.chmod(bundled, 0o755) + logger.info(f"Granted executable permission to {bundled}") + except Exception as e: + logger.warning(f"Failed to chmod {bundled}: {e}") + return str(bundled) + + # 2. Fallback to system path + if shutil.which("aria2c"): + return "aria2c" return None @@ -196,16 +217,34 @@ def _download_aria2(url, dest_path, progress_callback, cancel_check): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding='utf-8', - errors='ignore', - bufsize=1, - startupinfo=startupinfo - ) + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding='utf-8', + errors='ignore', + bufsize=1, + startupinfo=startupinfo + ) + except OSError as e: + # If bundled binary failed (e.g. library mismatch), try system 'aria2c' + if os.path.isabs(cmd[0]) and shutil.which("aria2c"): + logger.warning(f"Bundled aria2c failed ({e}), trying system 'aria2c'...") + cmd[0] = "aria2c" + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding='utf-8', + errors='ignore', + bufsize=1, + startupinfo=startupinfo + ) + else: + raise e # Progress regex: [#2b610d 0.9MiB/1.5MiB(58%) CN:1 DL:3.5MiB ETA:1s] # Capture percent and DL speed (DL:...) diff --git a/app/utils/path_utils.py b/app/utils/path_utils.py index a99e49b..ac17288 100644 --- a/app/utils/path_utils.py +++ b/app/utils/path_utils.py @@ -1,5 +1,8 @@ import os import sys +import subprocess +import platform +import shutil def get_resource_path(relative_path): """ Get absolute path to resource, works for dev and for PyInstaller """ @@ -10,3 +13,63 @@ def get_resource_path(relative_path): base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) + +from app.utils.logger import get_logger +logger = get_logger(__name__) + +def open_directory(path): + """ + Open the directory in the default file manager. + Platform-independent implementation. + """ + if not os.path.exists(path): + return False, f"Path not found: {path}" + + try: + if sys.platform == "win32": + os.startfile(path) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + # Linux/Unix + # Prioritize Dolphin (Steam Deck/KDE) + commands = ["dolphin", "xdg-open", "nautilus", "nemo", "thunar", "pcmanfm", "caja"] + success = False + last_error = "" + + for cmd in commands: + if shutil.which(cmd): + logger.info(f"Attempting to open directory with: {cmd}") + try: + # Sanitize environment for Linux subprocesses to prevent PyInstaller lib conflicts + env = os.environ.copy() + + # 1. Restore LD_LIBRARY_PATH + if 'LD_LIBRARY_PATH_ORIG' in env: + env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] + elif 'LD_LIBRARY_PATH' in env: + # If ORIG is not set (rare in PyInstaller), unset it to be safe + del env['LD_LIBRARY_PATH'] + + # 2. Clean Qt-specific variables (Critical for launching system Qt apps like Dolphin) + for key in list(env.keys()): + if key.startswith("QT_") or key.startswith("PYTHON"): + del env[key] + + # Use Popen. Wait shortly to check for immediate failure? + # xdg-open often returns immediately. + subprocess.Popen([cmd, path], stderr=subprocess.PIPE, env=env) + success = True + logger.info(f"Successfully launched {cmd}") + break + except Exception as e: + last_error = str(e) + logger.warning(f"Failed to launch {cmd}: {e}") + + if not success: + return False, f"No suitable file manager found. Last error: {last_error}" + + return True, "Opened successfully" + except Exception as e: + logger.error(f"open_directory failed: {e}") + return False, str(e) diff --git a/resources/bin/aria2c b/resources/bin/aria2c new file mode 100644 index 0000000..129ccd4 Binary files /dev/null and b/resources/bin/aria2c differ diff --git a/upx.exe b/upx.exe deleted file mode 100644 index c1ed36c..0000000 Binary files a/upx.exe and /dev/null differ