Initial public release v0.1.0 - Linux Wabbajack Modlist Application

Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
This commit is contained in:
Omni
2025-09-05 20:46:24 +01:00
commit cd591c14e3
445 changed files with 40398 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""
Jackify Frontends
User interface layers for CLI and GUI.
"""

View File

@@ -0,0 +1,5 @@
"""
Jackify CLI Frontend
Command-line interface for Jackify that uses the backend services.
"""

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Jackify CLI Frontend Entry Point
New entry point for the CLI frontend that uses the refactored structure.
"""
import sys
import signal
import logging
from .main import JackifyCLI
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def terminate_children(signum, frame):
"""Signal handler to terminate child processes on exit"""
print("Received signal, shutting down...")
sys.exit(0)
def main():
"""Main entry point for the CLI frontend"""
# Set up signal handlers
signal.signal(signal.SIGTERM, terminate_children)
signal.signal(signal.SIGINT, terminate_children)
try:
cli = JackifyCLI()
exit_code = cli.run()
sys.exit(exit_code or 0)
except KeyboardInterrupt:
print("\nOperation cancelled by user")
sys.exit(130) # Standard exit code for SIGINT
except Exception as e:
print(f"Fatal error: {e}")
logging.exception("Fatal error in CLI frontend")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
"""
CLI Commands
Individual command implementations for the CLI interface.
"""

View File

@@ -0,0 +1,159 @@
"""
Configure Modlist Command
CLI command for configuring a modlist post-install.
Extracted from the original jackify-cli.py.
"""
import os
import logging
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.configuration import ConfigurationContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class ConfigureModlistCommand:
"""Handler for the configure-modlist CLI command."""
def __init__(self, backend_services):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
"""
self.backend_services = backend_services
self.test_mode = False # TODO: Get from global config
def add_parser(self, subparsers):
"""Add the configure-modlist subcommand parser.
Args:
subparsers: The ArgumentParser subparsers object
"""
configure_modlist_parser = subparsers.add_parser(
"configure-modlist",
help="Configure a modlist post-install (for GUI integration)"
)
configure_modlist_parser.add_argument(
"--modlist-name",
type=str,
required=True,
help="Name of the modlist to configure (Steam shortcut name)"
)
configure_modlist_parser.add_argument(
"--install-dir",
type=str,
required=True,
help="Install directory of the modlist"
)
configure_modlist_parser.add_argument(
"--download-dir",
type=str,
help="Downloads directory (optional)"
)
configure_modlist_parser.add_argument(
"--nexus-api-key",
type=str,
help="Nexus API key (optional)"
)
configure_modlist_parser.add_argument(
"--mo2-exe-path",
type=str,
help="Path to ModOrganizer.exe (for AppID lookup)"
)
configure_modlist_parser.add_argument(
"--resolution",
type=str,
help="Resolution to set (optional)"
)
configure_modlist_parser.add_argument(
"--skip-confirmation",
action='store_true',
help="Skip confirmation prompts"
)
return configure_modlist_parser
def execute(self, args) -> int:
"""Execute the configure-modlist command.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting non-interactive modlist configuration (CLI mode)")
try:
# Build configuration context from args
context = self._build_context_from_args(args)
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_configuration(context)
logger.info("Finished non-interactive modlist configuration")
return 0 if result is not True else 1
except Exception as e:
logger.error(f"Failed to configure modlist: {e}")
print(f"{COLOR_ERROR}Configuration failed: {e}{COLOR_RESET}")
return 1
def _build_context_from_args(self, args) -> dict:
"""Build context dictionary from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
return {
'modlist_name': getattr(args, 'modlist_name', None),
'install_dir': getattr(args, 'install_dir', None),
'download_dir': getattr(args, 'download_dir', None),
'nexus_api_key': getattr(args, 'nexus_api_key', os.environ.get('NEXUS_API_KEY')),
'mo2_exe_path': getattr(args, 'mo2_exe_path', None),
'resolution': getattr(args, 'resolution', None),
'skip_confirmation': getattr(args, 'skip_confirmation', False),
'modlist_value': getattr(args, 'modlist_value', None),
'modlist_source': getattr(args, 'modlist_source', None),
}
def _execute_legacy_configuration(self, context: dict):
"""Execute configuration using legacy implementation.
This is a temporary bridge - will be replaced with backend service calls.
Args:
context: Configuration context dictionary
Returns:
Result from legacy configuration
"""
# Import backend services
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
# Create legacy handler instances
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(
config_handler=config_handler,
test_mode=self.test_mode
)
# Execute legacy configuration workflow
# The _configure_new_modlist method already handles Steam restart, manual steps, and configuration
result = modlist_menu._configure_new_modlist(
default_modlist_dir=context['install_dir'],
default_modlist_name=context['modlist_name']
)
# The _configure_new_modlist method already calls run_modlist_configuration_phase internally
# So we don't need to call it again here
return result

View File

@@ -0,0 +1,363 @@
"""
Install Modlist Command
CLI command for installing modlists.
Extracted from the original jackify-cli.py.
"""
import os
import logging
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.modlist import ModlistContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class InstallModlistCommand:
"""Handler for the install-modlist CLI command."""
def __init__(self, backend_services, system_info):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
system_info: System information (steamdeck flag, etc.)
"""
self.backend_services = backend_services
self.system_info = system_info
def add_top_level_args(self, parser):
"""Add top-level install-modlist arguments to the main parser.
Args:
parser: The main ArgumentParser
"""
parser.add_argument(
"--install-modlist",
action="store_true",
help="Enable modlist install/list feature (for GUI integration)"
)
parser.add_argument(
"--list-modlists",
action="store_true",
help="List available modlists for a game type (with --install-modlist)"
)
parser.add_argument(
"--install",
action="store_true",
help="Install a modlist non-interactively (with --install-modlist)"
)
parser.add_argument(
"--game-type",
type=str,
default=None,
help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)"
)
parser.add_argument(
"--modlist-value",
type=str,
help="Modlist identifier for online modlists"
)
def add_parser(self, subparsers):
"""Add the install-modlist subcommand parser.
Args:
subparsers: The ArgumentParser subparsers object
"""
install_modlist_parser = subparsers.add_parser(
"install-modlist",
help="Install or list available modlists"
)
install_modlist_parser.add_argument(
"--list",
action="store_true",
help="List available modlists for a game type"
)
install_modlist_parser.add_argument(
"--game-type",
type=str,
default=None,
help="Game type to filter modlists (skyrim, fallout4, falloutnv, oblivion, starfield, oblivion_remastered, other)"
)
return install_modlist_parser
def execute_top_level(self, args) -> int:
"""Execute top-level install-modlist functionality.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
if getattr(args, 'list_modlists', False):
return self.list_modlists(args)
elif getattr(args, 'install', False):
return self.install_modlist_auto(args)
else:
print(f"{COLOR_ERROR}No valid install-modlist operation specified{COLOR_RESET}")
return 1
def execute_subcommand(self, args) -> int:
"""Execute the install-modlist subcommand.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
if getattr(args, 'list', False):
return self.list_modlists(args)
else:
# Default behavior: run interactive modlist installation
logger.info("Starting interactive modlist installation via subcommand")
try:
# Use the working ModlistInstallCLI for interactive installation
from jackify.backend.core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Run interactive discovery phase
context = modlist_cli.run_discovery_phase()
if context:
# Run configuration phase (installation + Steam setup)
modlist_cli.configuration_phase()
logger.info("Interactive modlist installation completed successfully")
return 0
else:
logger.info("Modlist installation cancelled by user")
return 1
except Exception as e:
logger.error(f"Failed to install modlist: {e}")
print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}")
return 1
def list_modlists(self, args) -> int:
"""List available modlists for a game type.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Listing available modlists")
try:
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_list_modlists(args)
return 0
except Exception as e:
logger.error(f"Failed to list modlists: {e}")
print(f"{COLOR_ERROR}Failed to list modlists: {e}{COLOR_RESET}")
return 1
def install_modlist_auto(self, args) -> int:
"""Install a modlist non-interactively.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting non-interactive modlist installation")
try:
# Build context from args
context = self._build_install_context_from_args(args)
# Validate required fields
if not self._validate_install_context(context):
return 1
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_install(context)
logger.info("Finished non-interactive modlist installation")
return result
except Exception as e:
logger.error(f"Failed to install modlist: {e}")
print(f"{COLOR_ERROR}Installation failed: {e}{COLOR_RESET}")
return 1
def _build_install_context_from_args(self, args) -> dict:
"""Build installation context from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
return {
'modlist_name': getattr(args, 'modlist_name', None),
'install_dir': getattr(args, 'install_dir', None),
'download_dir': getattr(args, 'download_dir', None),
'nexus_api_key': os.environ.get('NEXUS_API_KEY'),
'game_type': getattr(args, 'game_type', None),
'modlist_value': getattr(args, 'modlist_value', None),
'skip_confirmation': True,
'resolution': getattr(args, 'resolution', None),
}
def _validate_install_context(self, context: dict) -> bool:
"""Validate installation context.
Args:
context: Installation context dictionary
Returns:
True if valid, False otherwise
"""
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
missing = [k for k in required_keys if not context.get(k)]
if is_gui_mode and missing:
print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}")
print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
return False
return True
def _execute_legacy_list_modlists(self, args):
"""Execute list modlists using backend implementation.
Args:
args: Parsed command-line arguments
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Get all modlists from engine
raw_modlists = modlist_cli.get_all_modlists_from_engine()
# Group by game type as in original CLI
game_type_map = {
'skyrim': ['Skyrim', 'Skyrim Special Edition'],
'fallout4': ['Fallout 4'],
'falloutnv': ['Fallout New Vegas'],
'oblivion': ['Oblivion'],
'starfield': ['Starfield'],
'oblivion_remastered': ['Oblivion Remastered', 'OblivionRemastered'],
'other': None
}
grouped_modlists = {k: [] for k in game_type_map}
for m_info in raw_modlists: # m_info is like {'id': ..., 'game': ...}
found_category = False
for cat_key, cat_keywords in game_type_map.items():
if cat_key == 'other':
continue
if cat_keywords:
for keyword in cat_keywords:
if keyword.lower() in m_info.get('game', '').lower():
grouped_modlists[cat_key].append(m_info)
found_category = True
break
if found_category:
break
if not found_category:
grouped_modlists['other'].append(m_info)
# Output modlists for the requested game type
game_type = (getattr(args, 'game_type', '') or '').lower()
if game_type and game_type in grouped_modlists:
for m in grouped_modlists[game_type]:
print(m.get('id', ''))
else:
# Output all modlists
for cat_key in ['skyrim', 'fallout4', 'falloutnv', 'oblivion', 'starfield', 'oblivion_remastered', 'other']:
for m in grouped_modlists[cat_key]:
print(m.get('id', ''))
def _execute_legacy_install(self, context: dict) -> int:
"""Execute installation using backend implementation.
Args:
context: Installation context dictionary
Returns:
Exit code
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.shared.colors import COLOR_WARNING, COLOR_PROMPT
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Detect game type and check support
game_type = None
wabbajack_file_path = context.get('wabbajack_file_path')
modlist_info = context.get('modlist_info')
if wabbajack_file_path:
game_type = modlist_cli.detect_game_type(wabbajack_file_path=wabbajack_file_path)
elif modlist_info:
game_type = modlist_cli.detect_game_type(modlist_info=modlist_info)
elif context.get('game_type'):
game_type = context['game_type']
# Check if game is supported
if game_type and not modlist_cli.check_game_support(game_type):
# Show unsupported game warning
supported_games = modlist_cli.wabbajack_parser.get_supported_games_display_names()
supported_games_str = ", ".join(supported_games)
print(f"\n{COLOR_WARNING}Game Support Notice{COLOR_RESET}")
print(f"{COLOR_WARNING}While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to: {supported_games_str}.{COLOR_RESET}")
print(f"{COLOR_WARNING}We are working to add more automated support in future releases!{COLOR_RESET}")
# Ask for confirmation to continue
response = input(f"{COLOR_PROMPT}Click Enter to continue with the modlist installation, or type 'cancel' to abort: {COLOR_RESET}").strip().lower()
if response == 'cancel':
print("[INFO] Modlist installation cancelled by user.")
return 1
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if is_gui_mode:
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
# For unsupported games, skip post-install configuration
if game_type and not modlist_cli.check_game_support(game_type):
print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}")
print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}")
return 0
else:
modlist_cli.configuration_phase()
return 0
else:
print("[INFO] Modlist installation cancelled or not confirmed.")
return 1
else:
# CLI mode: allow interactive prompts as before
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
# For unsupported games, skip post-install configuration
if game_type and not modlist_cli.check_game_support(game_type):
print(f"{COLOR_WARNING}Modlist installation completed successfully.{COLOR_RESET}")
print(f"{COLOR_WARNING}Note: Post-install configuration was skipped for unsupported game type: {game_type}{COLOR_RESET}")
return 0
else:
modlist_cli.configuration_phase()
return 0
else:
print("[INFO] Modlist installation cancelled or not confirmed.")
return 1

View File

@@ -0,0 +1,247 @@
"""
Tuxborn Command
CLI command for the Tuxborn Automatic Installer.
Extracted from the original jackify-cli.py.
"""
import os
import sys
import logging
from pathlib import Path
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.modlist import ModlistContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class TuxbornCommand:
"""Handler for the tuxborn-auto CLI command."""
def __init__(self, backend_services, system_info):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
system_info: System information (steamdeck flag, etc.)
"""
self.backend_services = backend_services
self.system_info = system_info
def add_args(self, parser):
"""Add tuxborn-auto arguments to the main parser.
Args:
parser: The main ArgumentParser
"""
parser.add_argument(
"--tuxborn-auto",
action="store_true",
help="Run the Tuxborn Automatic Installer non-interactively (for GUI integration)"
)
parser.add_argument(
"--install-dir",
type=str,
help="Install directory for Tuxborn (required with --tuxborn-auto)"
)
parser.add_argument(
"--download-dir",
type=str,
help="Downloads directory for Tuxborn (required with --tuxborn-auto)"
)
parser.add_argument(
"--modlist-name",
type=str,
default="Tuxborn",
help="Modlist name (optional, defaults to 'Tuxborn')"
)
def execute(self, args) -> int:
"""Execute the tuxborn-auto command.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting Tuxborn Automatic Installer (GUI integration mode)")
try:
# Set up logging redirection (copied from original)
self._setup_tee_logging()
# Build context from args
context = self._build_context_from_args(args)
# Validate required fields
if not self._validate_context(context):
return 1
# Use legacy implementation for now - will migrate to backend services later
result = self._execute_legacy_tuxborn(context)
logger.info("Finished Tuxborn Automatic Installer")
return result
except Exception as e:
logger.error(f"Failed to run Tuxborn installer: {e}")
print(f"{COLOR_ERROR}Tuxborn installation failed: {e}{COLOR_RESET}")
return 1
finally:
# Restore stdout/stderr
self._restore_stdout_stderr()
def _build_context_from_args(self, args) -> dict:
"""Build context dictionary from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
install_dir = getattr(args, 'install_dir', None)
download_dir = getattr(args, 'download_dir', None)
modlist_name = getattr(args, 'modlist_name', 'Tuxborn')
machineid = 'Tuxborn/Tuxborn'
# Try to get API key from saved config first, then environment variable
from jackify.backend.services.api_key_service import APIKeyService
api_key_service = APIKeyService()
api_key = api_key_service.get_saved_api_key()
if not api_key:
api_key = os.environ.get('NEXUS_API_KEY')
resolution = getattr(args, 'resolution', None)
mo2_exe_path = getattr(args, 'mo2_exe_path', None)
skip_confirmation = True # Always true in GUI mode
context = {
'machineid': machineid,
'modlist_name': modlist_name,
'install_dir': install_dir,
'download_dir': download_dir,
'nexus_api_key': api_key,
'skip_confirmation': skip_confirmation,
'resolution': resolution,
'mo2_exe_path': mo2_exe_path,
}
# PATCH: Always set modlist_value and modlist_source for Tuxborn workflow
context['modlist_value'] = 'Tuxborn/Tuxborn'
context['modlist_source'] = 'identifier'
return context
def _validate_context(self, context: dict) -> bool:
"""Validate Tuxborn context.
Args:
context: Tuxborn context dictionary
Returns:
True if valid, False otherwise
"""
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
missing = [k for k in required_keys if not context.get(k)]
if missing:
print(f"{COLOR_ERROR}Missing required arguments for --tuxborn-auto.\\n"
f"--install-dir, --download-dir, and NEXUS_API_KEY (env, 32+ chars) are required.{COLOR_RESET}")
return False
return True
def _setup_tee_logging(self):
"""Set up TEE logging (copied from original implementation)."""
import shutil
# TEE logging setup & log rotation (copied from original)
class TeeStdout:
def __init__(self, *files):
self.files = files
def write(self, data):
for f in self.files:
f.write(data)
f.flush()
def flush(self):
for f in self.files:
f.flush()
log_dir = Path.home() / "Jackify" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "tuxborn_workflow.log"
# Log rotation: keep last 3 logs, 1KB each (for testing)
max_logs = 3
max_size = 1024 # 1KB for testing
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
for i in range(max_logs, 0, -1):
prev = log_dir / f"tuxborn_workflow.log.{i-1}" if i > 1 else workflow_log_path
dest = log_dir / f"tuxborn_workflow.log.{i}"
if prev.exists():
if dest.exists():
dest.unlink()
prev.rename(dest)
self.workflow_log = open(workflow_log_path, 'a')
self.orig_stdout, self.orig_stderr = sys.stdout, sys.stderr
sys.stdout = TeeStdout(sys.stdout, self.workflow_log)
sys.stderr = TeeStdout(sys.stderr, self.workflow_log)
def _restore_stdout_stderr(self):
"""Restore original stdout/stderr."""
if hasattr(self, 'orig_stdout'):
sys.stdout = self.orig_stdout
sys.stderr = self.orig_stderr
if hasattr(self, 'workflow_log'):
self.workflow_log.close()
def _execute_legacy_tuxborn(self, context: dict) -> int:
"""Execute Tuxborn using legacy implementation.
Args:
context: Tuxborn context dictionary
Returns:
Exit code
"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.backend.handlers.menu_handler import MenuHandler
# Create legacy handler instances
menu_handler = MenuHandler()
modlist_cli = ModlistInstallCLI(
menu_handler=menu_handler,
steamdeck=self.system_info.get('is_steamdeck', False)
)
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
menu_handler.logger.info("Tuxborn discovery confirmed by GUI. Proceeding to configuration/installation.")
modlist_cli.configuration_phase()
# Handle GUI integration prompts (copied from original)
print('[PROMPT:RESTART_STEAM]')
if os.environ.get('JACKIFY_GUI_MODE'):
input() # Wait for GUI to send confirmation, no CLI prompt
else:
answer = input('Restart Steam automatically now? (Y/n): ')
# ... handle answer as before ...
print('[PROMPT:MANUAL_STEPS]')
if os.environ.get('JACKIFY_GUI_MODE'):
input() # Wait for GUI to send confirmation, no CLI prompt
else:
input('Once you have completed ALL the steps above, press Enter to continue...')
return 0
else:
menu_handler.logger.info("Tuxborn discovery/confirmation cancelled or failed (GUI mode).")
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
return 1

441
jackify/frontends/cli/main.py Executable file
View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Jackify CLI Frontend - Main Entry Point
Command-line interface for Jackify that uses the backend services.
Extracted and refactored from the original jackify-cli.py.
"""
import sys
import os
import argparse
import logging
# Import from our new backend structure
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
from jackify import __version__ as jackify_version
# Import our command handlers
from .commands.configure_modlist import ConfigureModlistCommand
from .commands.install_modlist import InstallModlistCommand
from .commands.tuxborn import TuxbornCommand
# Import our menu handlers
from .menus.main_menu import MainMenuHandler
from .menus.tuxborn_menu import TuxbornMenuHandler
from .menus.wabbajack_menu import WabbajackMenuHandler
from .menus.hoolamike_menu import HoolamikeMenuHandler
from .menus.additional_menu import AdditionalMenuHandler
# Import backend handlers for legacy compatibility
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.menu_handler import MenuHandler
from jackify.backend.handlers.mo2_handler import MO2Handler
logger = logging.getLogger(__name__)
class JackifyCLI:
"""Main application class for Jackify CLI Frontend"""
def __init__(self, test_mode=False, dev_mode=False):
"""Initialize the JackifyCLI frontend.
Args:
test_mode (bool): If True, run in test mode with minimal side effects
dev_mode (bool): If True, enable development features
"""
# Initialize early (debug flag not yet available)
self._debug_mode = False
# Set test mode flag
self.test_mode = test_mode
self.dev_mode = dev_mode
self.verbose = False
# Configure logging to be quiet by default - will be adjusted after arg parsing
self._configure_logging_early()
# Determine system info
self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
# Apply resource limits for optimal operation
self._apply_resource_limits()
# Initialize backend services
self.backend_services = self._initialize_backend_services()
# Initialize command handlers
self.commands = self._initialize_command_handlers()
# Initialize menu handlers with dev_mode
self.menus = self._initialize_menu_handlers()
# Initialize legacy compatibility attributes for menu bridge
self._initialize_legacy_compatibility()
# Initialize state variables
self.parser = None
self.subparsers = None
self.args = None
self.selected_modlist = None
self.setup_complete = False
def _debug_print(self, message):
"""Print debug message only if debug mode is enabled"""
if hasattr(self, '_debug_mode') and self._debug_mode:
logger.debug(message)
def _configure_logging_early(self):
"""Configure logging to be quiet during initialization, will be adjusted after arg parsing"""
# Set root logger to WARNING level initially to suppress INFO messages during init
logging.getLogger().setLevel(logging.WARNING)
# Configure basic logging format
if not logging.getLogger().handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
def _configure_logging_final(self):
"""Configure final logging level based on parsed arguments"""
# Use the existing LoggingHandler for proper log rotation
from jackify.backend.handlers.logging_handler import LoggingHandler
# Set up CLI-specific logging with rotation
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
cli_logger = logging_handler.setup_logger('jackify-cli', 'Modlist_Install_workflow_cli.log')
# Configure logging level
if self.args.debug:
cli_logger.setLevel(logging.DEBUG)
print("Debug logging enabled for console and file")
elif self.args.verbose:
cli_logger.setLevel(logging.INFO)
print("Verbose logging enabled for console and file")
else:
# Keep it at WARNING level for clean startup
cli_logger.setLevel(logging.WARNING)
def _is_steamdeck(self):
"""Check if running on Steam Deck"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
logger.info("Running on Steam Deck")
return True
logger.info("Not running on Steam Deck")
return False
except Exception as e:
logger.error(f"Error detecting Steam Deck: {e}")
return False
def _apply_resource_limits(self):
"""Apply recommended resource limits for optimal Jackify operation"""
try:
from jackify.backend.services.resource_manager import ResourceManager
resource_manager = ResourceManager()
success = resource_manager.apply_recommended_limits()
if success:
status = resource_manager.get_limit_status()
if status['target_achieved']:
logger.info(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
else:
logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
else:
# Log the issue but don't block startup
status = resource_manager.get_limit_status()
logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
# If we can't increase automatically, provide manual instructions in debug mode
if hasattr(self, '_debug_mode') and self._debug_mode:
instructions = resource_manager.get_manual_increase_instructions()
logger.debug(f"Manual increase instructions available for {instructions['distribution']}")
except Exception as e:
# Don't block startup on resource management errors
logger.warning(f"Error applying resource limits: {e}")
def _initialize_backend_services(self):
"""Initialize backend services.
Returns:
Dictionary of backend service instances
"""
# For now, create a basic modlist service
# TODO: Add other services as needed
services = {
'modlist_service': ModlistService(self.system_info)
}
return services
def _initialize_command_handlers(self):
"""Initialize command handler instances.
Returns:
Dictionary of command handler instances
"""
commands = {
'configure_modlist': ConfigureModlistCommand(self.backend_services),
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
}
return commands
def _initialize_menu_handlers(self):
"""Initialize menu handler instances.
Returns:
Dictionary of menu handler instances
"""
menus = {
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
'tuxborn': TuxbornMenuHandler(),
'wabbajack': WabbajackMenuHandler(),
'hoolamike': HoolamikeMenuHandler(),
'additional': AdditionalMenuHandler()
}
# Set up logging for menu handlers
for menu in menus.values():
menu.logger = logger
return menus
def _initialize_legacy_compatibility(self):
"""
Initialize legacy compatibility attributes for menu bridge.
This provides the legacy attributes that menu handlers expect from cli_instance
until the backend migration is complete.
"""
# LEGACY BRIDGE: Add legacy imports to access original handlers
# Backend handlers are now imported directly from backend package
try:
# Initialize legacy handlers for compatibility
self.config_handler = ConfigHandler()
self.filesystem_handler = FileSystemHandler()
self.path_handler = PathHandler()
self.shortcut_handler = ShortcutHandler(self.config_handler.settings)
self.menu = MenuHandler() # Original menu handler for fallback
self.menu_handler = self.menu # Alias for backend compatibility
# Add MO2 handler to the menu handler for additional tasks menu
self.menu.mo2_handler = MO2Handler(self.menu)
# Set steamdeck attribute that menus expect
self.steamdeck = self.system_info.is_steamdeck
# Initialize settings that legacy code expects
if not hasattr(self.config_handler, 'settings'):
self.config_handler.settings = {}
self.config_handler.settings['steamdeck'] = self.steamdeck
logger.info("Legacy compatibility layer initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize legacy compatibility layer: {e}")
# Continue anyway - some functionality might still work
self.config_handler = None
self.filesystem_handler = None
self.path_handler = None
self.shortcut_handler = None
self.menu = None
self.steamdeck = self.system_info.is_steamdeck
def run(self):
self.parser, self.subparsers, self.args = self._parse_args()
self._debug_mode = self.args.debug
self.verbose = self.args.verbose or self.args.debug
self.dev_mode = getattr(self.args, 'dev', False)
# Re-initialize menus with dev_mode after parsing args
self.menus = self._initialize_menu_handlers()
# Now that we have args, configure logging properly
self._configure_logging_final()
self._debug_print('Initializing Jackify CLI Frontend')
self._debug_print('JackifyCLI.run() called')
self._debug_print(f'Parsed args: {self.args}')
# Handle legacy restart-steam functionality (temporary)
if getattr(self.args, 'restart_steam', False):
self._debug_print('Entering restart_steam workflow')
return self._handle_restart_steam()
# Handle Tuxborn auto mode
if getattr(self.args, 'tuxborn_auto', False):
self._debug_print('Entering Tuxborn workflow')
return self.commands['tuxborn'].execute(self.args)
# Handle install-modlist top-level functionality
if getattr(self.args, 'install_modlist', False):
self._debug_print('Entering install_modlist workflow')
return self.commands['install_modlist'].execute_top_level(self.args)
# Handle subcommands
if getattr(self.args, 'command', None):
return self._run_command(self.args.command, self.args)
# Run interactive mode (legacy for now)
self._run_interactive()
def _parse_args(self):
"""Parse command-line arguments using command handlers"""
parser = argparse.ArgumentParser(description="Jackify: Wabbajack Modlist Manager for Linux/Steam Deck")
parser.add_argument("-V", "--version", action="store_true", help="Show Jackify version and exit")
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging (implies verbose)")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable informational console output")
parser.add_argument("--cli", action="store_true", help="Run in CLI mode (default if no GUI available)")
parser.add_argument("--resolution", type=str, help="Resolution to set (optional)")
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
# Add command-specific arguments
self.commands['tuxborn'].add_args(parser)
self.commands['install_modlist'].add_top_level_args(parser)
# Add subcommands
subparsers = parser.add_subparsers(dest="command", help="Command to run")
self.commands['configure_modlist'].add_parser(subparsers)
self.commands['install_modlist'].add_parser(subparsers)
args = parser.parse_args()
if args.version:
print(f"Jackify version {jackify_version}")
sys.exit(0)
return parser, subparsers, args
def _run_command(self, command, args):
"""Run a specific command using command handlers"""
if command == "install-modlist":
return self.commands['install_modlist'].execute_subcommand(args)
elif command == "configure-modlist":
return self.commands['configure_modlist'].execute(args)
elif command == "install-wabbajack":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_install_wabbajack()
elif command == "hoolamike":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_hoolamike()
elif command == "install-mo2":
print("MO2 installation not yet implemented")
print("This functionality is coming soon!")
return 1
elif command == "configure-nxm":
print("NXM configuration not yet implemented")
print("This functionality is coming soon!")
return 1
elif command == "recovery":
return self._handle_legacy_recovery(args)
elif command == "test-protontricks":
return self._handle_legacy_protontricks_test()
else:
print(f"Unknown command: {command}")
return 1
def _run_interactive(self):
"""Run the CLI interface interactively using the new menu system"""
try:
while True:
# Show main menu and get user's choice
choice = self.menus['main'].show_main_menu(self)
if choice == "exit":
print(f"{COLOR_INFO}Thank you for using Jackify!{COLOR_RESET}")
return 0
elif choice == "wabbajack":
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
elif choice == "tuxborn":
self.menus['tuxborn'].show_tuxborn_installer_menu(self)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif choice == "hoolamike":
# self.menus['hoolamike'].show_hoolamike_menu(self)
# elif choice == "additional":
# self.menus['additional'].show_additional_tasks_menu(self)
else:
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")
except KeyboardInterrupt:
print(f"\n{COLOR_INFO}Exiting Jackify...{COLOR_RESET}")
return 0
except Exception as e:
logger.error(f"Error in interactive mode: {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return 1
def _handle_restart_steam(self):
"""Handle restart-steam command - now properly implemented"""
print("[Jackify] Attempting to restart Steam...")
logger.debug("About to call secure_steam_restart()")
try:
# Use the already initialized shortcut_handler
if self.shortcut_handler:
success = self.shortcut_handler.secure_steam_restart()
logger.debug(f"secure_steam_restart() returned: {success}")
if success:
print("[Jackify] Steam restart completed successfully.")
return 0
else:
print("[Jackify] Failed to restart Steam.")
return 1
else:
print("[Jackify] ERROR: ShortcutHandler not initialized")
return 1
except Exception as e:
print(f"[Jackify] ERROR: Exception during Steam restart: {e}")
logger.error(f"Steam restart failed with exception: {e}")
return 1
def _handle_legacy_install_wabbajack(self):
"""Handle install-wabbajack command (legacy functionality)"""
print("Install Wabbajack functionality not yet migrated to new structure")
return 1
def _handle_legacy_hoolamike(self):
"""Handle hoolamike command (legacy functionality)"""
print("Hoolamike functionality not yet migrated to new structure")
return 1
def _handle_legacy_recovery(self, args):
"""Handle recovery command (legacy functionality)"""
print("Recovery functionality not yet migrated to new structure")
return 1
def _handle_legacy_protontricks_test(self):
"""Handle test-protontricks command (legacy functionality)"""
print("Protontricks test functionality not yet migrated to new structure")
return 1
# LEGACY BRIDGE: Methods that menu handlers expect to find on cli_instance
def _cmd_install_wabbajack(self, args):
"""LEGACY BRIDGE: Install Wabbajack application"""
return self._handle_legacy_install_wabbajack()
def main():
"""Legacy main function (not used in new structure)"""
pass
if __name__ == "__main__":
# This should not be called directly - use __main__.py instead
print("Please use: python -m jackify.frontends.cli")
sys.exit(1)

View File

@@ -0,0 +1,20 @@
"""
CLI Menu Components for Jackify Frontend
Extracted from the legacy monolithic CLI system
"""
from .main_menu import MainMenuHandler
from .tuxborn_menu import TuxbornMenuHandler
from .wabbajack_menu import WabbajackMenuHandler
from .hoolamike_menu import HoolamikeMenuHandler
from .additional_menu import AdditionalMenuHandler
from .recovery_menu import RecoveryMenuHandler
__all__ = [
'MainMenuHandler',
'TuxbornMenuHandler',
'WabbajackMenuHandler',
'HoolamikeMenuHandler',
'AdditionalMenuHandler',
'RecoveryMenuHandler'
]

View File

@@ -0,0 +1,73 @@
"""
Additional Tasks Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu()
"""
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class AdditionalMenuHandler:
"""
Handles the Additional Tasks menu (MO2, NXM Handling & Recovery)
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_additional_tasks_menu(self, cli_instance):
"""Show the MO2, NXM Handling & Recovery submenu"""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Utilities") # Broader title
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)")
print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools")
print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_mo2(cli_instance)
elif selection == "2":
print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}")
input("\nPress Enter to return to the Utilities menu...")
elif selection == "3":
self._execute_legacy_recovery_menu(cli_instance)
elif selection == "0":
break
else:
print("Invalid selection. Please try again.")
time.sleep(1)
def _execute_legacy_install_mo2(self, cli_instance):
"""LEGACY BRIDGE: Execute MO2 installation"""
# LEGACY BRIDGE: Use legacy imports until backend migration complete
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'mo2_handler'):
cli_instance.menu.mo2_handler.install_mo2()
else:
print(f"{COLOR_INFO}MO2 handler not available - this will be implemented in Phase 2.3{COLOR_RESET}")
input("\nPress Enter to continue...")
def _execute_legacy_recovery_menu(self, cli_instance):
"""LEGACY BRIDGE: Execute recovery menu"""
# This will be handled by the RecoveryMenuHandler
from .recovery_menu import RecoveryMenuHandler
recovery_handler = RecoveryMenuHandler()
recovery_handler.logger = self.logger
recovery_handler.show_recovery_menu(cli_instance)

View File

@@ -0,0 +1,32 @@
"""
Hoolamike Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_hoolamike_menu()
"""
from jackify.shared.colors import COLOR_INFO, COLOR_PROMPT, COLOR_RESET
class HoolamikeMenuHandler:
"""
Handles the Hoolamike Tasks menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def show_hoolamike_menu(self, cli_instance):
"""
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
Args:
cli_instance: Reference to main CLI instance for access to handlers
"""
print(f"{COLOR_INFO}Hoolamike menu functionality has been extracted but needs migration to backend services.{COLOR_RESET}")
print(f"{COLOR_INFO}This will be implemented in Phase 2.3 (Menu Backend Integration).{COLOR_RESET}")
# LEGACY BRIDGE: Use the original menu handler's method
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'show_hoolamike_menu'):
cli_instance.menu.show_hoolamike_menu(cli_instance)
else:
print(f"{COLOR_INFO}Legacy menu handler not available - returning to main menu.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

View File

@@ -0,0 +1,74 @@
"""
Main Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_main_menu()
"""
import time
from typing import Optional
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_ERROR
)
from jackify.shared.ui_utils import print_jackify_banner
class MainMenuHandler:
"""
Handles the main interactive menu display and user input routing
Extracted from legacy MenuHandler class
"""
def __init__(self, dev_mode=False):
self.logger = None # Will be set by CLI when needed
self.dev_mode = dev_mode
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_main_menu(self, cli_instance) -> str:
"""
Show the main menu and return user selection
Args:
cli_instance: Reference to main CLI instance for access to handlers
Returns:
str: Menu choice ("wabbajack", "hoolamike", "additional", "exit", "tuxborn")
"""
while True:
self._clear_screen()
print_jackify_banner()
print(f"{COLOR_SELECTION}Main Menu{COLOR_RESET}")
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tuxborn Automatic Installer")
print(f" {COLOR_ACTION}→ Simple, fully automated Tuxborn installation{COLOR_RESET}")
if self.dev_mode:
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks")
print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks")
print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
if self.dev_mode:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
else:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
return "tuxborn" # Will be handled by TuxbornMenuHandler
if self.dev_mode:
if choice == "3":
return "hoolamike"
elif choice == "4":
return "additional"
elif choice == "0":
return "exit"
else:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
time.sleep(1) # Brief pause for readability

View File

@@ -0,0 +1,174 @@
"""
Recovery Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler._show_recovery_menu()
"""
import logging
from pathlib import Path
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_PROMPT, COLOR_INFO, COLOR_ERROR
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class RecoveryMenuHandler:
"""
Handles the Recovery Tools menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_recovery_menu(self, cli_instance):
"""Show the recovery tools menu."""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header('Recovery Tools')
print(f"{COLOR_INFO}This allows restoring original Steam configuration files from backups created by Jackify.{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Restore all backups")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Restore config.vdf only")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Restore libraryfolders.vdf only")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Restore shortcuts.vdf only")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
if choice == "1":
self._restore_all_backups(cli_instance)
elif choice == "2":
self._restore_config_vdf(cli_instance)
elif choice == "3":
self._restore_libraryfolders_vdf(cli_instance)
elif choice == "4":
self._restore_shortcuts_vdf(cli_instance)
elif choice == "0":
break
else:
print("Invalid selection. Please try again.")
input("\nPress Enter to continue...")
def _restore_all_backups(self, cli_instance):
"""Restore all supported Steam config files"""
self.logger.info("Recovery selected: Restore all Steam config files")
print("\nAttempting to restore all supported Steam config files...")
# LEGACY BRIDGE: Use legacy handlers until backend migration complete
paths_to_check = {
"libraryfolders": self._get_library_vdf_path(cli_instance),
"config": self._get_config_vdf_path(cli_instance),
"shortcuts": self._get_shortcuts_vdf_path(cli_instance)
}
restored_count = 0
for file_type, file_path in paths_to_check.items():
if file_path:
print(f"Restoring {file_type} ({file_path})...")
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored {file_type}.")
restored_count += 1
else:
print(f"{COLOR_ERROR}Failed to restore {file_type} from {latest_backup}.{COLOR_RESET}")
else:
print(f"No backup found for {file_type}.")
else:
print(f"Could not locate original file for {file_type} to restore.")
print(f"\nRestore process completed. {restored_count}/{len(paths_to_check)} files potentially restored.")
input("\nPress Enter to continue...")
def _restore_config_vdf(self, cli_instance):
"""Restore config.vdf only"""
self.logger.info("Recovery selected: Restore config.vdf only")
print("\nAttempting to restore config.vdf...")
file_path = self._get_config_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored config.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore config.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for config.vdf.")
else:
print("Could not locate config.vdf.")
input("\nPress Enter to continue...")
def _restore_libraryfolders_vdf(self, cli_instance):
"""Restore libraryfolders.vdf only"""
self.logger.info("Recovery selected: Restore libraryfolders.vdf only")
print("\nAttempting to restore libraryfolders.vdf...")
file_path = self._get_library_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored libraryfolders.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore libraryfolders.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for libraryfolders.vdf.")
else:
print("Could not locate libraryfolders.vdf.")
input("\nPress Enter to continue...")
def _restore_shortcuts_vdf(self, cli_instance):
"""Restore shortcuts.vdf only"""
self.logger.info("Recovery selected: Restore shortcuts.vdf only")
print("\nAttempting to restore shortcuts.vdf...")
file_path = self._get_shortcuts_vdf_path(cli_instance)
if file_path:
latest_backup = self._find_latest_backup(cli_instance, Path(file_path))
if latest_backup:
if self._restore_backup(cli_instance, latest_backup, Path(file_path)):
print(f"Successfully restored shortcuts.vdf from {latest_backup}.")
else:
print(f"{COLOR_ERROR}Failed to restore shortcuts.vdf from {latest_backup}.{COLOR_RESET}")
else:
print("No backup found for shortcuts.vdf.")
else:
print("Could not locate shortcuts.vdf.")
input("\nPress Enter to continue...")
# LEGACY BRIDGE methods - delegate to existing handlers
def _get_library_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get libraryfolders.vdf path"""
if hasattr(cli_instance, 'path_handler'):
return cli_instance.path_handler.find_steam_library_vdf_path()
return None
def _get_config_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get config.vdf path"""
if hasattr(cli_instance, 'path_handler'):
return cli_instance.path_handler.find_steam_config_vdf()
return None
def _get_shortcuts_vdf_path(self, cli_instance):
"""LEGACY BRIDGE: Get shortcuts.vdf path"""
if hasattr(cli_instance, 'shortcut_handler'):
return cli_instance.shortcut_handler._find_shortcuts_vdf()
return None
def _find_latest_backup(self, cli_instance, file_path: Path):
"""LEGACY BRIDGE: Find latest backup file"""
if hasattr(cli_instance, 'filesystem_handler'):
return cli_instance.filesystem_handler.find_latest_backup(file_path)
return None
def _restore_backup(self, cli_instance, backup_path, target_path: Path) -> bool:
"""LEGACY BRIDGE: Restore backup file"""
if hasattr(cli_instance, 'filesystem_handler'):
return cli_instance.filesystem_handler.restore_backup(backup_path, target_path)
return False

View File

@@ -0,0 +1,194 @@
"""
Tuxborn Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_tuxborn_installer_menu()
"""
from pathlib import Path
from typing import Optional
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_WARNING
)
from jackify.shared.ui_utils import print_jackify_banner
from jackify.backend.handlers.config_handler import ConfigHandler
class TuxbornMenuHandler:
"""
Handles the Tuxborn Automatic Installer workflow
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def show_tuxborn_installer_menu(self, cli_instance):
"""
Implements the Tuxborn Automatic Installer workflow.
Prompts for install path, downloads path, and Nexus API key, then runs the one-shot install from start to finish
Args:
cli_instance: Reference to main CLI instance for access to handlers
"""
# Import backend service
from jackify.backend.core.modlist_operations import ModlistInstallCLI
print_jackify_banner()
print(f"{COLOR_SELECTION}Tuxborn Automatic Installer{COLOR_RESET}")
print(f"{COLOR_SELECTION}{'-'*32}{COLOR_RESET}")
print(f"{COLOR_INFO}This will install the Tuxborn modlist using the custom Jackify Install Engine in one automated flow.{COLOR_RESET}")
print(f"{COLOR_INFO}You will be prompted for the install location, downloads directory, and your Nexus API key.{COLOR_RESET}\n")
tuxborn_machineid = "Tuxborn/Tuxborn"
tuxborn_modlist_name = "Tuxborn"
# Prompt for install directory
print("----------------------------")
config_handler = ConfigHandler()
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
default_install_dir = base_install_dir / "Skyrim" / "Tuxborn"
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn installation.{COLOR_RESET}")
print(f"(Default: {default_install_dir})")
install_dir_result = self._get_directory_path_legacy(
cli_instance,
prompt_message=f"{COLOR_PROMPT}Install directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
default_path=default_install_dir,
create_if_missing=True,
no_header=True
)
if not install_dir_result:
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
if isinstance(install_dir_result, tuple):
install_dir, _ = install_dir_result # We'll use the path, creation handled by engine or later
else:
install_dir = install_dir_result
# Prompt for download directory
print("----------------------------")
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
default_download_dir = base_download_dir / "Tuxborn"
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn downloads.{COLOR_RESET}")
print(f"(Default: {default_download_dir})")
download_dir_result = self._get_directory_path_legacy(
cli_instance,
prompt_message=f"{COLOR_PROMPT}Download directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
default_path=default_download_dir,
create_if_missing=True,
no_header=True
)
if not download_dir_result:
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
if isinstance(download_dir_result, tuple):
download_dir, _ = download_dir_result # We'll use the path, creation handled by engine or later
else:
download_dir = download_dir_result
# Prompt for Nexus API key
print("----------------------------")
from jackify.backend.services.api_key_service import APIKeyService
api_key_service = APIKeyService()
saved_key = api_key_service.get_saved_api_key()
api_key = None
if saved_key:
print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}")
use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower()
if use_saved in ('', 'y', 'yes'):
api_key = saved_key
else:
new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip()
if new_key:
api_key = new_key
replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower()
if replace == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
else:
print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}")
else:
api_key = saved_key
else:
print(f"{COLOR_PROMPT}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}")
print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}")
print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}")
api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip()
if not api_key or api_key.lower() == 'q':
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
input("Press Enter to return to the main menu...")
return
save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower()
if save == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
else:
print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}")
# Context for ModlistInstallCLI
context = {
'machineid': tuxborn_machineid,
'modlist_name': tuxborn_modlist_name, # Will be used for shortcut name
'install_dir': install_dir_result, # Pass tuple (path, create_flag) or path
'download_dir': download_dir_result, # Pass tuple (path, create_flag) or path
'nexus_api_key': api_key,
'resolution': None
}
modlist_cli = ModlistInstallCLI(self, getattr(cli_instance, 'steamdeck', False))
# run_discovery_phase will use context_override, display summary, and ask for confirmation.
# If user confirms, it returns the context, otherwise None.
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
if confirmed_context:
if self.logger:
self.logger.info("Tuxborn discovery confirmed by user. Proceeding to configuration/installation.")
# The modlist_cli instance now holds the confirmed context.
# configuration_phase will use modlist_cli.context
modlist_cli.configuration_phase()
# After configuration_phase, messages about success or next steps are handled within it or by _configure_new_modlist
else:
if self.logger:
self.logger.info("Tuxborn discovery/confirmation cancelled or failed.")
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}")
return
def _get_directory_path_legacy(self, cli_instance, prompt_message: str, default_path: Optional[Path],
create_if_missing: bool = True, no_header: bool = False) -> Optional[Path]:
"""
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
Args:
cli_instance: Reference to main CLI instance
prompt_message: The prompt to show user
default_path: Default path if user presses Enter
create_if_missing: Whether to create directory if it doesn't exist
no_header: Whether to skip header display
Returns:
Path object or None if cancelled
"""
# LEGACY BRIDGE: Use the original menu handler's method
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'get_directory_path'):
return cli_instance.menu.get_directory_path(
prompt_message=prompt_message,
default_path=default_path,
create_if_missing=create_if_missing,
no_header=no_header
)
else:
# Fallback: simple input for now (will be replaced in future phases)
response = input(prompt_message).strip()
if response.lower() == 'q':
return None
elif response == '':
return default_path
else:
return Path(response)

View File

@@ -0,0 +1,115 @@
"""
Wabbajack Tasks Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_wabbajack_tasks_menu()
"""
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header
class WabbajackMenuHandler:
"""
Handles the Modlist and Wabbajack Tasks menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def _clear_screen(self):
"""Clear the terminal screen"""
import os
os.system('cls' if os.name == 'nt' else 'clear')
def show_wabbajack_tasks_menu(self, cli_instance):
"""Show the Modlist and Wabbajack Tasks menu"""
while True:
self._clear_screen()
print_jackify_banner()
# Use print_section_header for consistency
print_section_header("Modlist and Wabbajack Tasks")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)")
print(f" {COLOR_ACTION}→ Uses jackify-engine for a full install flow{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)")
print(f" {COLOR_ACTION}→ Modlist .wabbajack file downloaded? Configure it for Steam{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Configure Existing Modlist (In Steam)")
print(f" {COLOR_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}")
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
# print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via WINE){COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_modlist(cli_instance)
elif selection == "2":
self._execute_legacy_configure_new_modlist(cli_instance)
elif selection == "3":
self._execute_legacy_configure_existing_modlist(cli_instance)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif selection == "4":
# self._execute_legacy_install_wabbajack(cli_instance)
elif selection == "0":
break
else:
print("Invalid selection. Please try again.")
time.sleep(1)
def _execute_legacy_install_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute modlist installation workflow"""
# Import backend services
from jackify.backend.core.modlist_operations import ModlistInstallCLI
from jackify.backend.handlers.menu_handler import MenuHandler
# Create a proper MenuHandler instance with the required methods
menu_handler = MenuHandler()
# Pass the MenuHandler instance and steamdeck status
steamdeck_status = getattr(cli_instance, 'steamdeck', False)
installer = ModlistInstallCLI(menu_handler, steamdeck_status)
if self.logger:
self.logger.debug("MenuHandler: ModlistInstallCLI instance created for Install a Modlist.")
context = installer.run_discovery_phase()
if context:
if self.logger:
self.logger.info("MenuHandler: Discovery phase complete, proceeding to configuration phase.")
installer.configuration_phase()
else:
if self.logger:
self.logger.info("MenuHandler: Discovery phase did not return context. Skipping configuration.")
input("\nPress Enter to return to the Modlist Tasks menu...") # Standard return prompt
def _execute_legacy_install_wabbajack(self, cli_instance):
"""LEGACY BRIDGE: Execute Wabbajack application installation"""
if self.logger:
self.logger.info("User selected 'Install Wabbajack' from Modlist Tasks menu.")
# Add introductory text before calling the Wabbajack installation workflow
self._clear_screen()
print_jackify_banner()
print_section_header("Install Wabbajack Application")
print(f"{COLOR_INFO}This process will guide you through downloading and setting up\nthe Wabbajack application itself.{COLOR_RESET}")
print("\n") # Spacer
cli_instance._cmd_install_wabbajack(None) # Pass the cli_instance itself
def _execute_legacy_configure_new_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute new modlist configuration"""
# Import backend service
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
modlist_menu = ModlistMenuHandler(cli_instance.config_handler)
modlist_menu._configure_new_modlist()
def _execute_legacy_configure_existing_modlist(self, cli_instance):
"""LEGACY BRIDGE: Execute existing modlist configuration"""
# Import backend service
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
modlist_menu = ModlistMenuHandler(cli_instance.config_handler)
modlist_menu._configure_existing_modlist()

View File

@@ -0,0 +1,9 @@
"""
CLI UI Components for Jackify Frontend
Shared UI utilities and components for command-line interface
"""
# Currently empty - will be populated with UI helpers as needed
# Examples: input validators, progress indicators, etc.
__all__ = []

View File

@@ -0,0 +1,6 @@
"""
GUI Frontend for Jackify
PyQt-based graphical user interface that uses backend services directly
"""
__all__ = []

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""
Entry point for Jackify GUI Frontend
Usage: python -m jackify.frontends.gui
"""
from jackify.frontends.gui.main import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
"""
GUI Dialogs Package
Custom dialogs for the Jackify GUI application.
"""
from .completion_dialog import NextStepsDialog
from .success_dialog import SuccessDialog
__all__ = ['NextStepsDialog', 'SuccessDialog']

View File

@@ -0,0 +1,200 @@
"""
Completion Dialog
Custom completion dialog that shows the same detailed completion message
as the CLI frontend, formatted for GUI display.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit,
QWidget, QSpacerItem, QSizePolicy
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap, QIcon
logger = logging.getLogger(__name__)
class NextStepsDialog(QDialog):
"""
Custom completion dialog showing detailed next steps after modlist configuration.
Displays the same information as the CLI completion message but in a proper GUI format.
"""
def __init__(self, modlist_name: str, parent=None):
"""
Initialize the Next Steps dialog.
Args:
modlist_name: Name of the configured modlist
parent: Parent widget
"""
super().__init__(parent)
self.modlist_name = modlist_name
self.setWindowTitle("Next Steps")
self.setModal(True)
self.setFixedSize(600, 400)
# Set the Wabbajack icon if available
self._set_dialog_icon()
self._setup_ui()
logger.info(f"NextStepsDialog created for modlist: {modlist_name}")
def _set_dialog_icon(self):
"""Set the dialog icon to Wabbajack icon if available"""
try:
# Try to use the same icon as the main application
icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png"
if icon_path.exists():
icon = QIcon(str(icon_path))
self.setWindowIcon(icon)
except Exception as e:
logger.debug(f"Could not set dialog icon: {e}")
def _setup_ui(self):
"""Set up the dialog user interface"""
layout = QVBoxLayout(self)
layout.setSpacing(16)
layout.setContentsMargins(20, 20, 20, 20)
# Header with icon and title
self._setup_header(layout)
# Main content area
self._setup_content(layout)
# Action buttons
self._setup_buttons(layout)
def _setup_header(self, layout):
"""Set up the dialog header with title"""
header_layout = QHBoxLayout()
# Title
title_label = QLabel("Next Steps:")
title_label.setStyleSheet(
"QLabel { "
" font-size: 18px; "
" font-weight: bold; "
" color: #2c3e50; "
" margin-bottom: 10px; "
"}"
)
header_layout.addWidget(title_label)
# Add some space
header_layout.addStretch()
layout.addLayout(header_layout)
def _setup_content(self, layout):
"""Set up the main content area with next steps"""
# Create content area
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setSpacing(12)
# Add the detailed next steps text (matching CLI completion message)
steps_text = self._build_completion_text()
content_text = QTextEdit()
content_text.setPlainText(steps_text)
content_text.setReadOnly(True)
content_text.setStyleSheet(
"QTextEdit { "
" background-color: #f8f9fa; "
" border: 1px solid #dee2e6; "
" border-radius: 6px; "
" padding: 12px; "
" font-family: 'Segoe UI', Arial, sans-serif; "
" font-size: 12px; "
" line-height: 1.5; "
"}"
)
content_layout.addWidget(content_text)
layout.addWidget(content_widget)
def _setup_buttons(self, layout):
"""Set up the action buttons"""
button_layout = QHBoxLayout()
button_layout.setSpacing(12)
# Add stretch to center buttons
button_layout.addStretch()
# Return button (goes back to menu)
return_btn = QPushButton("Return")
return_btn.setFixedSize(100, 35)
return_btn.clicked.connect(self.accept) # This will close dialog and return to menu
return_btn.setStyleSheet(
"QPushButton { "
" background-color: #3498db; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #2980b9; "
"} "
"QPushButton:pressed { "
" background-color: #21618c; "
"}"
)
button_layout.addWidget(return_btn)
button_layout.addSpacing(10)
# Exit button (closes the application)
exit_btn = QPushButton("Exit")
exit_btn.setFixedSize(100, 35)
exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
"}"
)
button_layout.addWidget(exit_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
def _build_completion_text(self) -> str:
"""
Build the completion text matching the CLI version from menu_handler.py.
Returns:
Formatted completion text string
"""
# Match the CLI completion text from menu_handler.py lines 627-631
completion_text = f"""✓ Configuration completed successfully!
Modlist Install and Configuration complete!:
• You should now be able to Launch '{self.modlist_name}' through Steam.
• Congratulations and enjoy the game!
Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log"""
return completion_text

View File

@@ -0,0 +1,328 @@
"""
Protontricks Error Dialog
Dialog shown when protontricks is not found, with options to install via Flatpak or get native installation guidance.
"""
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSizePolicy, QTextEdit, QProgressBar
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QPixmap, QIcon, QFont
from .. import shared_theme
class FlatpakInstallThread(QThread):
"""Thread for installing Flatpak protontricks"""
finished = Signal(bool, str) # success, message
def __init__(self, detection_service):
super().__init__()
self.detection_service = detection_service
def run(self):
success, message = self.detection_service.install_flatpak_protontricks()
self.finished.emit(success, message)
class ProtontricksErrorDialog(QDialog):
"""
Dialog shown when protontricks is not found
Provides options to install via Flatpak or get native installation guidance
"""
def __init__(self, detection_service, parent=None):
super().__init__(parent)
self.detection_service = detection_service
self.setWindowTitle("Protontricks Required")
self.setModal(True)
self.setFixedSize(550, 520)
self.install_thread = None
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Card background
card = QFrame(self)
card.setObjectName("protontricksCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setMinimumWidth(500)
card.setMinimumHeight(400)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card_layout = QVBoxLayout(card)
card_layout.setSpacing(16)
card_layout.setContentsMargins(28, 28, 28, 28)
card.setStyleSheet(
"QFrame#protontricksCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e74c3c; "
"}"
)
# Error icon
icon_label = QLabel()
icon_label.setAlignment(Qt.AlignCenter)
icon_label.setText("!")
icon_label.setStyleSheet(
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e74c3c; "
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(icon_label)
# Error title
title_label = QLabel("Protontricks Not Found")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e74c3c; "
" margin-bottom: 2px; "
"}"
)
card_layout.addWidget(title_label)
# Error message
message_text = QTextEdit()
message_text.setReadOnly(True)
message_text.setPlainText(
"Protontricks is required for Jackify to function properly. "
"It manages Wine prefixes for Steam games and is essential for modlist installation and configuration.\n\n"
"Choose an installation method below:"
)
message_text.setMinimumHeight(100)
message_text.setMaximumHeight(120)
message_text.setStyleSheet(
"QTextEdit { "
" font-size: 15px; "
" color: #e0e0e0; "
" background: transparent; "
" border: none; "
" line-height: 1.3; "
" margin-bottom: 6px; "
"}"
)
card_layout.addWidget(message_text)
# Progress bar (initially hidden)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
self.progress_bar.setStyleSheet(
"QProgressBar { "
" border: 1px solid #555; "
" border-radius: 4px; "
" background: #23272e; "
" text-align: center; "
"} "
"QProgressBar::chunk { "
" background-color: #4fc3f7; "
" border-radius: 3px; "
"}"
)
card_layout.addWidget(self.progress_bar)
# Status label (initially hidden)
self.status_label = QLabel()
self.status_label.setVisible(False)
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet(
"QLabel { "
" font-size: 14px; "
" color: #4fc3f7; "
" margin: 8px 0; "
"}"
)
card_layout.addWidget(self.status_label)
# Button layout
button_layout = QVBoxLayout()
button_layout.setSpacing(12)
# Flatpak install button
self.flatpak_btn = QPushButton("Install via Flatpak (Recommended)")
self.flatpak_btn.setFixedHeight(40)
self.flatpak_btn.clicked.connect(self._install_flatpak)
self.flatpak_btn.setStyleSheet(
"QPushButton { "
" background-color: #4fc3f7; "
" color: white; "
" border: none; "
" border-radius: 6px; "
" font-weight: bold; "
" font-size: 14px; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #3498db; "
"} "
"QPushButton:pressed { "
" background-color: #2980b9; "
"} "
"QPushButton:disabled { "
" background-color: #555; "
" color: #888; "
"}"
)
button_layout.addWidget(self.flatpak_btn)
# Native install guidance button
self.native_btn = QPushButton("Show Native Installation Instructions")
self.native_btn.setFixedHeight(40)
self.native_btn.clicked.connect(self._show_native_guidance)
self.native_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" color: white; "
" border: none; "
" border-radius: 6px; "
" font-weight: bold; "
" font-size: 14px; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
"}"
)
button_layout.addWidget(self.native_btn)
card_layout.addLayout(button_layout)
# Bottom button layout
bottom_layout = QHBoxLayout()
bottom_layout.setSpacing(12)
# Re-detect button
self.redetect_btn = QPushButton("Re-detect")
self.redetect_btn.setFixedSize(120, 36)
self.redetect_btn.clicked.connect(self._redetect)
self.redetect_btn.setStyleSheet(
"QPushButton { "
" background-color: #27ae60; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #229954; "
"} "
"QPushButton:pressed { "
" background-color: #1e8449; "
"}"
)
bottom_layout.addWidget(self.redetect_btn)
bottom_layout.addStretch()
# Exit button
exit_btn = QPushButton("Exit Jackify")
exit_btn.setFixedSize(120, 36)
exit_btn.clicked.connect(self._exit_app)
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #e74c3c; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #c0392b; "
"} "
"QPushButton:pressed { "
" background-color: #a93226; "
"}"
)
bottom_layout.addWidget(exit_btn)
card_layout.addLayout(bottom_layout)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addStretch()
def _install_flatpak(self):
"""Install protontricks via Flatpak"""
# Disable buttons during installation
self.flatpak_btn.setEnabled(False)
self.native_btn.setEnabled(False)
self.redetect_btn.setEnabled(False)
# Show progress
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate progress
self.status_label.setVisible(True)
self.status_label.setText("Installing Flatpak protontricks...")
# Start installation thread
self.install_thread = FlatpakInstallThread(self.detection_service)
self.install_thread.finished.connect(self._on_install_finished)
self.install_thread.start()
def _on_install_finished(self, success, message):
"""Handle installation completion"""
# Hide progress
self.progress_bar.setVisible(False)
# Re-enable buttons
self.flatpak_btn.setEnabled(True)
self.native_btn.setEnabled(True)
self.redetect_btn.setEnabled(True)
if success:
self.status_label.setText("✓ Installation successful!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
# Auto-redetect after successful installation
self._redetect()
else:
self.status_label.setText(f"✗ Installation failed: {message}")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
def _show_native_guidance(self):
"""Show native installation guidance"""
from ..services.message_service import MessageService
guidance = self.detection_service.get_installation_guidance()
MessageService.information(self, "Native Installation", guidance, safety_level="low")
def _redetect(self):
"""Re-detect protontricks"""
self.detection_service.clear_cache()
is_installed, installation_type, details = self.detection_service.detect_protontricks(use_cache=False)
if is_installed:
self.status_label.setText("✓ Protontricks found!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
self.accept() # Close dialog successfully
else:
self.status_label.setText("✗ Protontricks still not found")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
def _exit_app(self):
"""Exit the application"""
self.reject()
import sys
sys.exit(1)
def closeEvent(self, event):
"""Handle dialog close event"""
if self.install_thread and self.install_thread.isRunning():
self.install_thread.terminate()
self.install_thread.wait()
event.accept()

View File

@@ -0,0 +1,239 @@
"""
Success Dialog
Celebration dialog shown when workflows complete successfully.
Features trophy icon, personalized messaging, and time tracking.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget,
QSpacerItem, QSizePolicy, QFrame, QApplication
)
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QPixmap, QIcon, QFont
logger = logging.getLogger(__name__)
class SuccessDialog(QDialog):
"""
Celebration dialog shown when workflows complete successfully.
Features:
- Trophy icon
- Personalized success message
- Time taken display
- Next steps guidance
- Return and Exit buttons
"""
def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None):
super().__init__(parent)
self.modlist_name = modlist_name
self.workflow_type = workflow_type
self.time_taken = time_taken
self.game_name = game_name
self.setWindowTitle("Success!")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setFixedSize(500, 420)
self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True)
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# --- Card background for content ---
card = QFrame(self)
card.setObjectName("successCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(440)
card_layout = QVBoxLayout(card)
card_layout.setSpacing(12)
card_layout.setContentsMargins(28, 28, 28, 28)
card.setStyleSheet(
"QFrame#successCard { "
" background: #23272e; "
" border-radius: 12px; "
" border: 1px solid #353a40; "
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Trophy icon (smaller, more subtle)
trophy_label = QLabel()
trophy_label.setAlignment(Qt.AlignCenter)
trophy_icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "trophy.png"
if trophy_icon_path.exists():
pixmap = QPixmap(str(trophy_icon_path)).scaled(36, 36, Qt.KeepAspectRatio, Qt.SmoothTransformation)
trophy_label.setPixmap(pixmap)
else:
trophy_label.setText("")
trophy_label.setStyleSheet(
"QLabel { "
" font-size: 28px; "
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(trophy_label)
# Success title (less saturated green)
title_label = QLabel("Success!")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 22px; "
" font-weight: 600; "
" color: #2ecc71; "
" margin-bottom: 2px; "
"}"
)
card_layout.addWidget(title_label)
# Personalized success message (modlist name in Jackify Blue, but less bold)
message_text = self._build_success_message()
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
if self.workflow_type == "install":
message_html = f"<span style='font-size:15px;'>{modlist_name_html} installed successfully!</span>"
else:
message_html = message_text
message_label = QLabel(message_html)
message_label.setAlignment(Qt.AlignCenter)
message_label.setWordWrap(True)
message_label.setStyleSheet(
"QLabel { "
" font-size: 15px; "
" color: #e0e0e0; "
" line-height: 1.3; "
" margin-bottom: 6px; "
" max-width: 400px; "
" min-width: 200px; "
" word-wrap: break-word; "
"}"
)
message_label.setTextFormat(Qt.RichText)
card_layout.addWidget(message_label)
# Time taken
time_label = QLabel(f"Completed in {self.time_taken}")
time_label.setAlignment(Qt.AlignCenter)
time_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #b0b0b0; "
" font-style: italic; "
" margin-bottom: 10px; "
"}"
)
card_layout.addWidget(time_label)
# Next steps guidance
next_steps_text = self._build_next_steps()
next_steps_label = QLabel(next_steps_text)
next_steps_label.setAlignment(Qt.AlignCenter)
next_steps_label.setWordWrap(True)
next_steps_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #b0b0b0; "
" line-height: 1.2; "
" padding: 6px; "
" background-color: transparent; "
" border-radius: 6px; "
" border: none; "
"}"
)
card_layout.addWidget(next_steps_label)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addStretch()
# Action buttons
btn_row = QHBoxLayout()
self.return_btn = QPushButton("Return")
self.exit_btn = QPushButton("Exit")
btn_row.addWidget(self.return_btn)
btn_row.addWidget(self.exit_btn)
layout.addLayout(btn_row)
# Now set up the timer/countdown logic AFTER buttons are created
self.return_btn.setEnabled(False)
self.exit_btn.setEnabled(False)
self._countdown = 3
self._orig_return_text = self.return_btn.text()
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_countdown)
self._update_countdown()
self._timer.start(1000)
self.return_btn.clicked.connect(self.accept)
self.exit_btn.clicked.connect(QApplication.quit)
# Set the Wabbajack icon if available
self._set_dialog_icon()
logger.info(f"SuccessDialog created for {workflow_type}: {modlist_name} (completed in {time_taken})")
def _set_dialog_icon(self):
"""Set the dialog icon to Wabbajack icon if available"""
try:
# Try to use the same icon as the main application
icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png"
if icon_path.exists():
icon = QIcon(str(icon_path))
self.setWindowIcon(icon)
except Exception as e:
logger.debug(f"Could not set dialog icon: {e}")
def _setup_ui(self):
"""Set up the dialog user interface"""
pass # This method is no longer needed as __init__ handles UI setup
def _setup_buttons(self, layout):
"""Set up the action buttons"""
pass # This method is no longer needed as __init__ handles button setup
def _build_success_message(self) -> str:
"""
Build the personalized success message based on workflow type.
Returns:
Formatted success message string
"""
workflow_messages = {
"install": f"{self.modlist_name} installed successfully!",
"configure_new": f"{self.modlist_name} configured successfully!",
"configure_existing": f"{self.modlist_name} configuration updated successfully!",
"tuxborn": f"Tuxborn installation completed successfully!",
}
return workflow_messages.get(self.workflow_type, f"{self.modlist_name} completed successfully!")
def _build_next_steps(self) -> str:
"""
Build the next steps guidance based on workflow type.
Returns:
Formatted next steps string
"""
game_display = self.game_name or self.modlist_name
if self.workflow_type == "tuxborn":
return f"You can now launch Tuxborn from Steam and enjoy your modded {game_display} experience!"
else:
return f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
def _update_countdown(self):
if self._countdown > 0:
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
self.return_btn.setEnabled(False)
self.exit_btn.setEnabled(False)
self._countdown -= 1
else:
self.return_btn.setText(self._orig_return_text)
self.return_btn.setEnabled(True)
self.exit_btn.setEnabled(True)
self._timer.stop()

View File

@@ -0,0 +1,529 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ulimit Guidance Dialog
Provides guidance for manually increasing file descriptor limits when automatic
increase fails. Offers distribution-specific instructions and commands.
"""
import logging
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QGroupBox, QTabWidget, QWidget, QScrollArea,
QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QFont, QIcon
logger = logging.getLogger(__name__)
class UlimitGuidanceDialog(QDialog):
"""Dialog to provide manual ulimit increase guidance when automatic methods fail"""
def __init__(self, resource_manager=None, parent=None):
super().__init__(parent)
self.resource_manager = resource_manager
self.setWindowTitle("File Descriptor Limit Guidance")
self.setModal(True)
self.setMinimumSize(800, 600)
self.resize(900, 700)
# Get current status and instructions
if self.resource_manager:
self.status = self.resource_manager.get_limit_status()
self.instructions = self.resource_manager.get_manual_increase_instructions()
else:
# Fallback if no resource manager provided
from jackify.backend.services.resource_manager import ResourceManager
temp_manager = ResourceManager()
self.status = temp_manager.get_limit_status()
self.instructions = temp_manager.get_manual_increase_instructions()
self._setup_ui()
# Auto-refresh status every few seconds
self.refresh_timer = QTimer()
self.refresh_timer.timeout.connect(self._refresh_status)
self.refresh_timer.start(3000) # Refresh every 3 seconds
def _setup_ui(self):
"""Set up the user interface"""
layout = QVBoxLayout()
self.setLayout(layout)
# Title and current status
self._create_header(layout)
# Main content with tabs
self._create_content_tabs(layout)
# Action buttons
self._create_action_buttons(layout)
# Apply styling
self._apply_styling()
def _create_header(self, layout):
"""Create header with current status"""
header_frame = QFrame()
header_frame.setFrameStyle(QFrame.StyledPanel)
header_layout = QVBoxLayout()
header_frame.setLayout(header_layout)
# Title
title_label = QLabel("File Descriptor Limit Configuration")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setAlignment(Qt.AlignCenter)
header_layout.addWidget(title_label)
# Status information
self._create_status_section(header_layout)
layout.addWidget(header_frame)
def _create_status_section(self, layout):
"""Create current status display"""
status_layout = QHBoxLayout()
# Current limits
current_label = QLabel(f"Current Limit: {self.status['current_soft']}")
target_label = QLabel(f"Target Limit: {self.status['target_limit']}")
max_label = QLabel(f"Maximum Possible: {self.status['max_possible']}")
# Status indicator
if self.status['target_achieved']:
status_text = "✓ Optimal"
status_color = "#4caf50" # Green
elif self.status['can_increase']:
status_text = "⚠ Can Improve"
status_color = "#ff9800" # Orange
else:
status_text = "✗ Needs Manual Fix"
status_color = "#f44336" # Red
self.status_label = QLabel(f"Status: {status_text}")
self.status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
status_layout.addWidget(current_label)
status_layout.addWidget(target_label)
status_layout.addWidget(max_label)
status_layout.addStretch()
status_layout.addWidget(self.status_label)
layout.addLayout(status_layout)
def _create_content_tabs(self, layout):
"""Create tabbed content with different guidance types"""
self.tab_widget = QTabWidget()
# Quick Fix tab
self._create_quick_fix_tab()
# Permanent Fix tab
self._create_permanent_fix_tab()
# Troubleshooting tab
self._create_troubleshooting_tab()
layout.addWidget(self.tab_widget)
def _create_quick_fix_tab(self):
"""Create quick/temporary fix tab"""
widget = QWidget()
layout = QVBoxLayout()
widget.setLayout(layout)
# Explanation
explanation = QLabel(
"Quick fixes apply only to the current terminal session. "
"You'll need to run these commands each time you start Jackify from a new terminal."
)
explanation.setWordWrap(True)
explanation.setStyleSheet("color: #666; font-style: italic; margin-bottom: 10px;")
layout.addWidget(explanation)
# Commands group
commands_group = QGroupBox("Commands to Run")
commands_layout = QVBoxLayout()
commands_group.setLayout(commands_layout)
# Command text
if 'temporary' in self.instructions['methods']:
temp_method = self.instructions['methods']['temporary']
commands_text = QTextEdit()
commands_text.setPlainText('\n'.join(temp_method['commands']))
commands_text.setMaximumHeight(120)
commands_text.setFont(QFont("monospace"))
commands_layout.addWidget(commands_text)
# Note
if 'note' in temp_method:
note_label = QLabel(f"Note: {temp_method['note']}")
note_label.setWordWrap(True)
note_label.setStyleSheet("color: #666; font-style: italic;")
commands_layout.addWidget(note_label)
layout.addWidget(commands_group)
# Current session test
test_group = QGroupBox("Test Current Session")
test_layout = QVBoxLayout()
test_group.setLayout(test_layout)
test_label = QLabel("You can test if the commands worked by running:")
test_layout.addWidget(test_label)
test_command = QTextEdit()
test_command.setPlainText("ulimit -n")
test_command.setMaximumHeight(40)
test_command.setFont(QFont("monospace"))
test_layout.addWidget(test_command)
expected_label = QLabel(f"Expected result: {self.instructions['target_limit']} or higher")
expected_label.setStyleSheet("color: #666;")
test_layout.addWidget(expected_label)
layout.addWidget(test_group)
layout.addStretch()
self.tab_widget.addTab(widget, "Quick Fix")
def _create_permanent_fix_tab(self):
"""Create permanent fix tab"""
widget = QWidget()
layout = QVBoxLayout()
widget.setLayout(layout)
# Explanation
explanation = QLabel(
"Permanent fixes modify system configuration files and require administrator privileges. "
"Changes take effect after logout/login or system reboot."
)
explanation.setWordWrap(True)
explanation.setStyleSheet("color: #666; font-style: italic; margin-bottom: 10px;")
layout.addWidget(explanation)
# Distribution detection
distro_label = QLabel(f"Detected Distribution: {self.instructions['distribution'].title()}")
distro_label.setStyleSheet("font-weight: bold; color: #333;")
layout.addWidget(distro_label)
# Commands group
commands_group = QGroupBox("System Configuration Commands")
commands_layout = QVBoxLayout()
commands_group.setLayout(commands_layout)
# Warning
warning_label = QLabel(
"⚠️ WARNING: These commands require root/sudo privileges and modify system files. "
"Make sure you understand what each command does before running it."
)
warning_label.setWordWrap(True)
warning_label.setStyleSheet("background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px; color: #856404;")
commands_layout.addWidget(warning_label)
# Command text
if 'permanent' in self.instructions['methods']:
perm_method = self.instructions['methods']['permanent']
commands_text = QTextEdit()
commands_text.setPlainText('\n'.join(perm_method['commands']))
commands_text.setMinimumHeight(200)
commands_text.setFont(QFont("monospace"))
commands_layout.addWidget(commands_text)
# Note
if 'note' in perm_method:
note_label = QLabel(f"Note: {perm_method['note']}")
note_label.setWordWrap(True)
note_label.setStyleSheet("color: #666; font-style: italic;")
commands_layout.addWidget(note_label)
layout.addWidget(commands_group)
# Verification group
verify_group = QGroupBox("Verification After Reboot/Re-login")
verify_layout = QVBoxLayout()
verify_group.setLayout(verify_layout)
verify_label = QLabel("After rebooting or logging out and back in, verify the change:")
verify_layout.addWidget(verify_label)
verify_command = QTextEdit()
verify_command.setPlainText("ulimit -n")
verify_command.setMaximumHeight(40)
verify_command.setFont(QFont("monospace"))
verify_layout.addWidget(verify_command)
expected_label = QLabel(f"Expected result: {self.instructions['target_limit']} or higher")
expected_label.setStyleSheet("color: #666;")
verify_layout.addWidget(expected_label)
layout.addWidget(verify_group)
layout.addStretch()
self.tab_widget.addTab(widget, "Permanent Fix")
def _create_troubleshooting_tab(self):
"""Create troubleshooting tab"""
widget = QWidget()
layout = QVBoxLayout()
widget.setLayout(layout)
# Create scrollable area for troubleshooting content
scroll = QScrollArea()
scroll_widget = QWidget()
scroll_layout = QVBoxLayout()
scroll_widget.setLayout(scroll_layout)
# Common issues
issues_group = QGroupBox("Common Issues and Solutions")
issues_layout = QVBoxLayout()
issues_group.setLayout(issues_layout)
issues_text = """
<b>Issue:</b> "Operation not permitted" when trying to increase limits<br>
<b>Solution:</b> You may need root privileges or the hard limit may be too low. Try the permanent fix method.
<b>Issue:</b> Changes don't persist after closing terminal<br>
<b>Solution:</b> Use the permanent fix method to modify system configuration files.
<b>Issue:</b> Still getting "too many open files" errors after increasing limits<br>
<b>Solution:</b> Some applications may need to be restarted to pick up the new limits. Try restarting Jackify.
<b>Issue:</b> Can't increase above a certain number<br>
<b>Solution:</b> The hard limit may be set by system administrator or systemd. Check systemd service limits if applicable.
"""
issues_label = QLabel(issues_text)
issues_label.setWordWrap(True)
issues_label.setTextFormat(Qt.RichText)
issues_layout.addWidget(issues_label)
scroll_layout.addWidget(issues_group)
# System information
sysinfo_group = QGroupBox("System Information")
sysinfo_layout = QVBoxLayout()
sysinfo_group.setLayout(sysinfo_layout)
sysinfo_text = f"""
<b>Current Soft Limit:</b> {self.status['current_soft']}<br>
<b>Current Hard Limit:</b> {self.status['current_hard']}<br>
<b>Target Limit:</b> {self.status['target_limit']}<br>
<b>Detected Distribution:</b> {self.instructions['distribution']}<br>
<b>Can Increase Automatically:</b> {"Yes" if self.status['can_increase'] else "No"}<br>
<b>Target Achieved:</b> {"Yes" if self.status['target_achieved'] else "No"}
"""
sysinfo_label = QLabel(sysinfo_text)
sysinfo_label.setWordWrap(True)
sysinfo_label.setTextFormat(Qt.RichText)
sysinfo_label.setFont(QFont("monospace", 9))
sysinfo_layout.addWidget(sysinfo_label)
scroll_layout.addWidget(sysinfo_group)
# Additional resources
resources_group = QGroupBox("Additional Resources")
resources_layout = QVBoxLayout()
resources_group.setLayout(resources_layout)
resources_text = """
<b>For more help:</b><br>
• Check your distribution's documentation for ulimit configuration<br>
• Search for "increase file descriptor limit [your_distribution]"<br>
• Consider asking on your distribution's support forums<br>
• Jackify documentation and issue tracker on GitHub
"""
resources_label = QLabel(resources_text)
resources_label.setWordWrap(True)
resources_label.setTextFormat(Qt.RichText)
resources_layout.addWidget(resources_label)
scroll_layout.addWidget(resources_group)
scroll_layout.addStretch()
scroll.setWidget(scroll_widget)
scroll.setWidgetResizable(True)
layout.addWidget(scroll)
self.tab_widget.addTab(widget, "Troubleshooting")
def _create_action_buttons(self, layout):
"""Create action buttons"""
button_layout = QHBoxLayout()
# Try Again button
self.try_again_btn = QPushButton("Try Automatic Fix Again")
self.try_again_btn.clicked.connect(self._try_automatic_fix)
self.try_again_btn.setEnabled(self.status['can_increase'] and not self.status['target_achieved'])
# Refresh Status button
refresh_btn = QPushButton("Refresh Status")
refresh_btn.clicked.connect(self._refresh_status)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
close_btn.setDefault(True)
button_layout.addWidget(self.try_again_btn)
button_layout.addWidget(refresh_btn)
button_layout.addStretch()
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
def _apply_styling(self):
"""Apply dialog styling"""
self.setStyleSheet("""
QDialog {
background-color: #f5f5f5;
}
QGroupBox {
font-weight: bold;
border: 2px solid #cccccc;
border-radius: 5px;
margin-top: 1ex;
padding-top: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px 0 5px;
}
QTextEdit {
background-color: #ffffff;
border: 1px solid #cccccc;
border-radius: 3px;
padding: 5px;
}
QPushButton {
background-color: #007acc;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005a9e;
}
QPushButton:pressed {
background-color: #004175;
}
QPushButton:disabled {
background-color: #cccccc;
color: #666666;
}
""")
def _try_automatic_fix(self):
"""Try automatic fix again"""
if self.resource_manager:
success = self.resource_manager.apply_recommended_limits()
if success:
self._refresh_status()
from jackify.frontends.gui.services.message_service import MessageService
MessageService.information(
self,
"Success",
"File descriptor limits have been increased successfully!",
safety_level="low"
)
else:
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(
self,
"Fix Failed",
"Automatic fix failed. Please try the manual methods shown in the tabs above.",
safety_level="medium"
)
def _refresh_status(self):
"""Refresh current status display"""
try:
if self.resource_manager:
self.status = self.resource_manager.get_limit_status()
else:
from jackify.backend.services.resource_manager import ResourceManager
temp_manager = ResourceManager()
self.status = temp_manager.get_limit_status()
# Update status display in header
header_frame = self.layout().itemAt(0).widget()
if header_frame:
# Find and update status section
header_layout = header_frame.layout()
status_layout = header_layout.itemAt(1).layout()
# Update individual labels
status_layout.itemAt(0).widget().setText(f"Current Limit: {self.status['current_soft']}")
status_layout.itemAt(1).widget().setText(f"Target Limit: {self.status['target_limit']}")
status_layout.itemAt(2).widget().setText(f"Maximum Possible: {self.status['max_possible']}")
# Update status indicator
if self.status['target_achieved']:
status_text = "✓ Optimal"
status_color = "#4caf50" # Green
elif self.status['can_increase']:
status_text = "⚠ Can Improve"
status_color = "#ff9800" # Orange
else:
status_text = "✗ Needs Manual Fix"
status_color = "#f44336" # Red
self.status_label.setText(f"Status: {status_text}")
self.status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
# Update try again button
self.try_again_btn.setEnabled(self.status['can_increase'] and not self.status['target_achieved'])
except Exception as e:
logger.warning(f"Error refreshing status: {e}")
def closeEvent(self, event):
"""Handle dialog close event"""
if hasattr(self, 'refresh_timer'):
self.refresh_timer.stop()
event.accept()
# Convenience function for easy use
def show_ulimit_guidance(parent=None, resource_manager=None):
"""
Show the ulimit guidance dialog
Args:
parent: Parent widget for the dialog
resource_manager: Optional ResourceManager instance
Returns:
Dialog result (QDialog.Accepted or QDialog.Rejected)
"""
dialog = UlimitGuidanceDialog(resource_manager, parent)
return dialog.exec()
if __name__ == "__main__":
# Test the dialog
import sys
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
# Create and show dialog
result = show_ulimit_guidance()
sys.exit(result)

View File

@@ -0,0 +1,188 @@
"""
Warning Dialog
Custom warning dialog for destructive actions (e.g., deleting directory contents).
Matches Jackify theming and requires explicit user confirmation.
"""
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QFrame, QSizePolicy, QTextEdit
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap, QIcon, QFont
from .. import shared_theme
class WarningDialog(QDialog):
"""
Jackify-themed warning dialog for dangerous/destructive actions.
Requires user to type 'DELETE' to confirm.
"""
def __init__(self, warning_message: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Warning!")
self.setModal(True)
# Increased height for better text display, scalable for 800p screens
self.setFixedSize(500, 440)
self.confirmed = False
self._setup_ui(warning_message)
def _setup_ui(self, warning_message):
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Card background
card = QFrame(self)
card.setObjectName("warningCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setMinimumWidth(440)
card.setMinimumHeight(320)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card_layout = QVBoxLayout(card)
card_layout.setSpacing(16)
card_layout.setContentsMargins(28, 28, 28, 28)
card.setStyleSheet(
"QFrame#warningCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e67e22; "
"}"
)
# Warning icon
icon_label = QLabel()
icon_label.setAlignment(Qt.AlignCenter)
icon_label.setText("!")
icon_label.setStyleSheet(
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e67e22; "
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(icon_label)
# Warning title
title_label = QLabel("Potentially Destructive Action!")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e67e22; "
" margin-bottom: 2px; "
"}"
)
card_layout.addWidget(title_label)
# Warning message (use a scrollable text area for long messages)
message_text = QTextEdit()
message_text.setReadOnly(True)
message_text.setPlainText(warning_message)
message_text.setMinimumHeight(80)
message_text.setMaximumHeight(160)
message_text.setStyleSheet(
"QTextEdit { "
" font-size: 15px; "
" color: #e0e0e0; "
" background: transparent; "
" border: none; "
" line-height: 1.3; "
" margin-bottom: 6px; "
" max-width: 400px; "
" min-width: 200px; "
"}"
)
card_layout.addWidget(message_text)
# Confirmation entry
confirm_label = QLabel("Type 'DELETE' to confirm:")
confirm_label.setAlignment(Qt.AlignCenter)
confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" margin-bottom: 2px; "
"}"
)
card_layout.addWidget(confirm_label)
self.confirm_edit = QLineEdit()
self.confirm_edit.setAlignment(Qt.AlignCenter)
self.confirm_edit.setPlaceholderText("DELETE")
self.confirm_edit.setStyleSheet(
"QLineEdit { "
" font-size: 15px; "
" border: 1px solid #e67e22; "
" border-radius: 6px; "
" padding: 6px; "
" background: #23272e; "
" color: #e67e22; "
"}"
)
card_layout.addWidget(self.confirm_edit)
# Action buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(12)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedSize(120, 36)
cancel_btn.clicked.connect(self.reject)
cancel_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
"}"
)
button_layout.addWidget(cancel_btn)
confirm_btn = QPushButton("Proceed")
confirm_btn.setFixedSize(120, 36)
confirm_btn.clicked.connect(self._on_confirm)
confirm_btn.setStyleSheet(
"QPushButton { "
" background-color: #e67e22; "
" color: white; "
" border: none; "
" border-radius: 4px; "
" font-weight: bold; "
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #d35400; "
"} "
"QPushButton:pressed { "
" background-color: #b34700; "
"}"
)
button_layout.addWidget(confirm_btn)
button_layout.addStretch()
card_layout.addLayout(button_layout)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addStretch()
def _on_confirm(self):
if self.confirm_edit.text().strip().upper() == "DELETE":
self.confirmed = True
self.accept()
else:
self.confirm_edit.setText("")
self.confirm_edit.setPlaceholderText("Type DELETE to confirm")
self.confirm_edit.setStyleSheet(self.confirm_edit.styleSheet() + "QLineEdit { background: #3b2323; }")

View File

@@ -0,0 +1,819 @@
"""
Jackify GUI Frontend Main Application
Main entry point for the Jackify GUI application using PySide6.
This replaces the legacy jackify_gui implementation with a refactored architecture.
"""
import sys
import os
from pathlib import Path
# Suppress xkbcommon locale errors (harmless but annoying)
os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;*.warning=false'
os.environ['QT_ENABLE_GLYPH_CACHE_WORKAROUND'] = '1'
# Hidden diagnostic flag for debugging PyInstaller environment issues - must be first
if '--env-diagnostic' in sys.argv:
import json
from datetime import datetime
print("🔍 PyInstaller Environment Diagnostic")
print("=" * 50)
# Check if we're in PyInstaller
is_frozen = getattr(sys, 'frozen', False)
meipass = getattr(sys, '_MEIPASS', None)
print(f"Frozen: {is_frozen}")
print(f"_MEIPASS: {meipass}")
# Capture environment data
env_data = {
'timestamp': datetime.now().isoformat(),
'context': 'pyinstaller_internal',
'frozen': is_frozen,
'meipass': meipass,
'python_executable': sys.executable,
'working_directory': os.getcwd(),
'sys_path': sys.path,
}
# PyInstaller-specific environment variables
pyinstaller_vars = {}
for key, value in os.environ.items():
if any(term in key.lower() for term in ['mei', 'pyinstaller', 'tmp']):
pyinstaller_vars[key] = value
env_data['pyinstaller_vars'] = pyinstaller_vars
# Check LD_LIBRARY_PATH
ld_path = os.environ.get('LD_LIBRARY_PATH', '')
if ld_path:
suspicious = [p for p in ld_path.split(':') if 'mei' in p.lower() or 'tmp' in p.lower()]
env_data['ld_library_path'] = ld_path
env_data['ld_library_path_suspicious'] = suspicious
# Try to find jackify-engine from PyInstaller context
engine_paths = []
if meipass:
meipass_path = Path(meipass)
potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine"
if potential_engine.exists():
engine_paths.append(str(potential_engine))
env_data['engine_paths_found'] = engine_paths
# Output the results
print("\n📊 Environment Data:")
print(json.dumps(env_data, indent=2))
# Save to file
try:
output_file = Path.cwd() / "pyinstaller_env_capture.json"
with open(output_file, 'w') as f:
json.dump(env_data, f, indent=2)
print(f"\n💾 Data saved to: {output_file}")
except Exception as e:
print(f"\n❌ Could not save data: {e}")
sys.exit(0)
from jackify import __version__ as jackify_version
if '--help' in sys.argv or '-h' in sys.argv:
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""")
sys.exit(0)
if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv:
print(f"Jackify version {jackify_version}")
sys.exit(0)
from jackify import __version__
# Add src directory to Python path
src_dir = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(src_dir))
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton,
QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle
)
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QIcon
import json
# Import backend services and models
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import DEBUG_BORDERS
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
print(message)
# Constants for styling and disclaimer
DISCLAIMER_TEXT = (
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
"Please back up your data and use caution."
)
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks"),
("Tuxborn Automatic Installer", "tuxborn_installer"),
("Hoolamike Tasks", "hoolamike_tasks"),
("Additional Tasks", "additional_tasks"),
("Exit Jackify", "exit_jackify"),
]
class FeaturePlaceholder(QWidget):
"""Placeholder widget for features not yet implemented"""
def __init__(self, stacked_widget=None):
super().__init__()
layout = QVBoxLayout()
label = QLabel("[Feature screen placeholder]")
label.setAlignment(Qt.AlignCenter)
layout.addWidget(label)
back_btn = QPushButton("Back to Main Menu")
if stacked_widget:
back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0))
layout.addWidget(back_btn)
self.setLayout(layout)
class SettingsDialog(QDialog):
def __init__(self, parent=None):
try:
super().__init__(parent)
from jackify.backend.handlers.config_handler import ConfigHandler
self.config_handler = ConfigHandler()
self._original_debug_mode = self.config_handler.get('debug_mode', False)
self.setWindowTitle("Settings")
self.setModal(True)
self.setMinimumWidth(750)
self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }")
main_layout = QVBoxLayout()
self.setLayout(main_layout)
# --- Resource Limits Section ---
resource_group = QGroupBox("Resource Limits")
resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
resource_layout = QGridLayout()
resource_group.setLayout(resource_layout)
resource_layout.setVerticalSpacing(4)
resource_layout.setHorizontalSpacing(8)
resource_layout.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft)
resource_layout.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft)
self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json")
self.resource_settings = self._load_json(self.resource_settings_path)
self.resource_edits = {}
resource_row_index = 0
for resource_row_index, (k, v) in enumerate(self.resource_settings.items(), start=1):
# Create resource label with optional inline checkbox for File Extractor
if k == "File Extractor":
# Create horizontal layout for File Extractor with inline checkbox
resource_row = QHBoxLayout()
resource_label = QLabel(f"{k}:", parent=self)
resource_row.addWidget(resource_label)
resource_row.addSpacing(10) # Add some spacing
multithreading_checkbox = QCheckBox("Multithreading (Experimental)")
multithreading_checkbox.setChecked(v.get('_7zzMultiThread', 'off') == 'on')
multithreading_checkbox.setToolTip("Enables multithreaded file extraction using 7-Zip. May improve extraction speed on multi-core systems but could be less stable.")
multithreading_checkbox.setStyleSheet("color: #fff;")
resource_row.addWidget(multithreading_checkbox)
resource_row.addStretch() # Push checkbox to the left
# Add the horizontal layout to the grid
resource_layout.addLayout(resource_row, resource_row_index, 0)
else:
resource_layout.addWidget(QLabel(f"{k}:", parent=self), resource_row_index, 0, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(160)
resource_layout.addWidget(max_tasks_spin, resource_row_index, 1)
# Store the widgets (checkbox for File Extractor, None for others)
if k == "File Extractor":
self.resource_edits[k] = (multithreading_checkbox, max_tasks_spin)
else:
self.resource_edits[k] = (None, max_tasks_spin)
# Bandwidth limiter row
self.app_settings_path = os.path.expanduser("~/.config/jackify/app_settings.json")
self.app_settings = self._load_json(self.app_settings_path)
self.bandwidth_spin = QSpinBox()
self.bandwidth_spin.setMinimum(0)
self.bandwidth_spin.setMaximum(1000000)
self.bandwidth_spin.setValue(self.app_settings.get("MaxDownloadSpeedKBps", 0))
self.bandwidth_spin.setSuffix(" KB/s")
self.bandwidth_spin.setFixedWidth(160)
self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.")
bandwidth_note = QLabel("(0 = unlimited)")
bandwidth_note.setStyleSheet("color: #aaa; font-size: 10pt;")
# Create horizontal layout for bandwidth row
bandwidth_row = QHBoxLayout()
bandwidth_row.addWidget(self.bandwidth_spin)
bandwidth_row.addWidget(bandwidth_note)
bandwidth_row.addStretch() # Push to the left
resource_layout.addWidget(QLabel("Bandwidth Limit:", parent=self), resource_row_index+1, 0, 1, 1, Qt.AlignLeft)
resource_layout.addLayout(bandwidth_row, resource_row_index+1, 1)
main_layout.addWidget(resource_group)
main_layout.addSpacing(12)
# --- Debug & Diagnostics Section ---
debug_group = QGroupBox("Debug & Diagnostics")
debug_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
debug_layout = QVBoxLayout()
debug_group.setLayout(debug_layout)
self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)")
# Load debug_mode from config
self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False))
self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.")
self.debug_checkbox.setStyleSheet("color: #fff;")
debug_layout.addWidget(self.debug_checkbox)
main_layout.addWidget(debug_group)
main_layout.addSpacing(12)
# --- Nexus API Key Section ---
api_group = QGroupBox("Nexus API Key")
api_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
api_layout = QHBoxLayout()
api_group.setLayout(api_layout)
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
api_key = self.config_handler.get_api_key()
if api_key:
self.api_key_edit.setText(api_key)
else:
self.api_key_edit.setText("")
self.api_key_edit.setToolTip("Your Nexus API Key (obfuscated by default, click Show to reveal)")
# Connect for immediate saving when text changes
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
self.api_show_btn = QToolButton()
self.api_show_btn.setCheckable(True)
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
self.api_show_btn.setToolTip("Show or hide your API key")
self.api_show_btn.toggled.connect(self._toggle_api_key_visibility)
self.api_show_btn.setStyleSheet("")
clear_api_btn = QPushButton("Clear API Key")
clear_api_btn.clicked.connect(self._clear_api_key)
api_layout.addWidget(QLabel("Nexus API Key:"))
api_layout.addWidget(self.api_key_edit)
api_layout.addWidget(self.api_show_btn)
api_layout.addWidget(clear_api_btn)
main_layout.addWidget(api_group)
main_layout.addSpacing(12)
# --- Directories & Paths Section ---
dir_group = QGroupBox("Directories & Paths")
dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
dir_layout = QFormLayout()
dir_group.setLayout(dir_layout)
self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", ""))
self.install_dir_edit.setToolTip("Default directory for modlist installations.")
self.install_dir_btn = QPushButton()
self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.install_dir_btn.setToolTip("Browse for directory")
self.install_dir_btn.setFixedWidth(32)
self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit))
install_dir_row = QHBoxLayout()
install_dir_row.addWidget(self.install_dir_edit)
install_dir_row.addWidget(self.install_dir_btn)
dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row)
self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", ""))
self.download_dir_edit.setToolTip("Default directory for modlist downloads.")
self.download_dir_btn = QPushButton()
self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.download_dir_btn.setToolTip("Browse for directory")
self.download_dir_btn.setFixedWidth(32)
self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit))
download_dir_row = QHBoxLayout()
download_dir_row.addWidget(self.download_dir_edit)
download_dir_row.addWidget(self.download_dir_btn)
dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row)
main_layout.addWidget(dir_group)
main_layout.addSpacing(12)
# --- Save/Close/Help Buttons ---
btn_layout = QHBoxLayout()
self.help_btn = QPushButton("Help")
self.help_btn.setToolTip("Help/documentation coming soon!")
self.help_btn.clicked.connect(self._show_help)
btn_layout.addWidget(self.help_btn)
btn_layout.addStretch(1)
save_btn = QPushButton("Save")
close_btn = QPushButton("Close")
save_btn.clicked.connect(self._save)
close_btn.clicked.connect(self.reject)
btn_layout.addWidget(save_btn)
btn_layout.addWidget(close_btn)
main_layout.addSpacing(10)
main_layout.addLayout(btn_layout)
# Set tab order for accessibility
# Get the first resource's widgets
first_resource_key = list(self.resource_edits.keys())[0]
first_multithreading, first_max_tasks = self.resource_edits[first_resource_key]
# Set tab order starting with the first max tasks spinner
self.setTabOrder(first_max_tasks, self.bandwidth_spin)
self.setTabOrder(self.bandwidth_spin, self.debug_checkbox)
self.setTabOrder(self.debug_checkbox, self.api_key_edit)
self.setTabOrder(self.api_key_edit, self.api_show_btn)
self.setTabOrder(self.api_show_btn, clear_api_btn)
self.setTabOrder(clear_api_btn, self.install_dir_edit)
self.setTabOrder(self.install_dir_edit, self.install_dir_btn)
self.setTabOrder(self.install_dir_btn, self.download_dir_edit)
self.setTabOrder(self.download_dir_edit, self.download_dir_btn)
self.setTabOrder(self.download_dir_btn, save_btn)
self.setTabOrder(save_btn, close_btn)
self.error_label = QLabel("")
self.error_label.setStyleSheet("color: #f55; font-weight: bold;")
main_layout.insertWidget(0, self.error_label)
except Exception as e:
print(f"[ERROR] Exception in SettingsDialog __init__: {e}")
import traceback
traceback.print_exc()
raise
def _toggle_api_key_visibility(self, checked):
# Always use the same eyeball icon, only change color when toggled
eye_icon = QIcon.fromTheme("view-visible")
if not eye_icon.isNull():
self.api_show_btn.setIcon(eye_icon)
self.api_show_btn.setText("")
else:
self.api_show_btn.setIcon(QIcon())
self.api_show_btn.setText("\U0001F441") # 👁
if checked:
self.api_key_edit.setEchoMode(QLineEdit.Normal)
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }") # Jackify blue
else:
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_show_btn.setStyleSheet("")
def _pick_directory(self, line_edit):
dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~"))
if dir_path:
line_edit.setText(dir_path)
def _show_help(self):
from jackify.frontends.gui.services.message_service import MessageService
MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low")
def _load_json(self, path):
if os.path.exists(path):
try:
with open(path, 'r') as f:
return json.load(f)
except Exception:
return {}
return {}
def _save_json(self, path, data):
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium")
def _clear_api_key(self):
self.api_key_edit.setText("")
self.config_handler.clear_api_key()
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
def _on_api_key_changed(self, text):
"""Handle immediate API key saving when text changes"""
api_key = text.strip()
self.config_handler.save_api_key(api_key)
def _save(self):
# Validate values
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
if max_tasks_spin.value() > 128:
self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.")
return
if self.bandwidth_spin.value() > 1000000:
self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.")
return
self.error_label.setText("")
# Save resource settings
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
resource_data = self.resource_settings.get(k, {})
resource_data['MaxTasks'] = max_tasks_spin.value()
# Only add multithreading setting for File Extractor
if k == "File Extractor" and multithreading_checkbox:
if multithreading_checkbox.isChecked():
resource_data['_7zzMultiThread'] = 'on'
else:
# Remove the setting if unchecked (don't add 'off')
resource_data.pop('_7zzMultiThread', None)
self.resource_settings[k] = resource_data
self._save_json(self.resource_settings_path, self.resource_settings)
# Save debug mode to config
self.config_handler.set('debug_mode', self.debug_checkbox.isChecked())
# Save bandwidth limit
self.app_settings["MaxDownloadSpeedKBps"] = self.bandwidth_spin.value()
self._save_json(self.app_settings_path, self.app_settings)
# Save API key
api_key = self.api_key_edit.text().strip()
self.config_handler.save_api_key(api_key)
# Save modlist base dirs
self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip())
self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip())
self.config_handler.save_config()
# Check if debug mode changed and prompt for restart
new_debug_mode = self.debug_checkbox.isChecked()
if new_debug_mode != self._original_debug_mode:
reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="low")
if reply == QMessageBox.Yes:
import os, sys
if getattr(sys, 'frozen', False):
# PyInstaller bundle: safe to restart
self.accept()
os.execv(sys.executable, [sys.executable] + sys.argv)
return
else:
# Dev mode: show message instead of auto-restart
MessageService.information(self, "Manual Restart Required", "Please restart Jackify manually to apply debug mode changes.", safety_level="low")
self.accept()
return
MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low")
self.accept()
def _bold_label(self, text):
label = QLabel(text)
label.setStyleSheet("font-weight: bold; color: #fff;")
return label
class JackifyMainWindow(QMainWindow):
"""Main window for Jackify GUI application"""
def __init__(self, dev_mode=False):
super().__init__()
self.setWindowTitle("Jackify")
self.setMinimumSize(1400, 950)
self.resize(1400, 900)
# Initialize backend services
self._initialize_backend()
# Set up UI
self._setup_ui(dev_mode=dev_mode)
# Set up cleanup
QApplication.instance().aboutToQuit.connect(self.cleanup_processes)
def _initialize_backend(self):
"""Initialize backend services for direct use (no subprocess)"""
# Determine system info
self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
# Apply resource limits for optimal operation
self._apply_resource_limits()
# Initialize backend services
self.backend_services = {
'modlist_service': ModlistService(self.system_info)
}
# Initialize GUI services
self.gui_services = {}
# Initialize protontricks detection service
from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService
self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck)
debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
def _is_steamdeck(self):
"""Check if running on Steam Deck"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
return True
return False
except Exception:
return False
def _apply_resource_limits(self):
"""Apply recommended resource limits for optimal Jackify operation"""
try:
from jackify.backend.services.resource_manager import ResourceManager
resource_manager = ResourceManager()
success = resource_manager.apply_recommended_limits()
if success:
status = resource_manager.get_limit_status()
if status['target_achieved']:
debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
else:
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
else:
# Log the issue but don't block startup
status = resource_manager.get_limit_status()
print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
# Check if debug mode is enabled for additional info
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
instructions = resource_manager.get_manual_increase_instructions()
print(f"Manual increase instructions available for {instructions['distribution']}")
except Exception as e:
# Don't block startup on resource management errors
print(f"Warning: Error applying resource limits: {e}")
def _setup_ui(self, dev_mode=False):
"""Set up the user interface"""
# Create stacked widget for screen navigation
self.stacked_widget = QStackedWidget()
# Create screens using refactored codebase
from jackify.frontends.gui.screens import (
MainMenu, TuxbornInstallerScreen, ModlistTasksScreen,
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
)
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
self.modlist_tasks_screen = ModlistTasksScreen(
stacked_widget=self.stacked_widget,
main_menu_index=0,
dev_mode=dev_mode
)
self.tuxborn_screen = TuxbornInstallerScreen(
stacked_widget=self.stacked_widget,
main_menu_index=0
)
self.install_modlist_screen = InstallModlistScreen(
stacked_widget=self.stacked_widget,
main_menu_index=3
)
self.configure_new_modlist_screen = ConfigureNewModlistScreen(
stacked_widget=self.stacked_widget,
main_menu_index=3
)
self.configure_existing_modlist_screen = ConfigureExistingModlistScreen(
stacked_widget=self.stacked_widget,
main_menu_index=3
)
# Add screens to stacked widget
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
self.stacked_widget.addWidget(self.tuxborn_screen) # Index 1: Tuxborn Installer
self.stacked_widget.addWidget(self.feature_placeholder) # Index 2: Placeholder
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 3: Modlist Tasks
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 5: Configure New
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 6: Configure Existing
# Add debug tracking for screen changes
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
# --- Persistent Bottom Bar ---
bottom_bar = QWidget()
bottom_bar_layout = QHBoxLayout()
bottom_bar_layout.setContentsMargins(10, 2, 10, 2)
bottom_bar_layout.setSpacing(0)
bottom_bar.setLayout(bottom_bar_layout)
bottom_bar.setFixedHeight(32)
bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;"
if DEBUG_BORDERS:
bottom_bar_style += " border: 2px solid lime;"
bottom_bar.setStyleSheet(bottom_bar_style)
# Version label (left)
version_label = QLabel(f"Jackify v{__version__}")
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
# Spacer
bottom_bar_layout.addStretch(1)
# Settings button (right)
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
settings_btn.setOpenExternalLinks(False)
settings_btn.linkActivated.connect(self.open_settings_dialog)
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
# --- Main Layout ---
central_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(self.stacked_widget, stretch=1) # Screen takes all available space
main_layout.addWidget(bottom_bar) # Bottom bar stays at bottom
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
# Start with main menu
self.stacked_widget.setCurrentIndex(0)
# Check for protontricks after UI is set up
self._check_protontricks_on_startup()
def _debug_screen_change(self, index):
"""Debug method to track screen changes"""
# Only show debug info if debug mode is enabled
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if not config_handler.get('debug_mode', False):
return
screen_names = {
0: "Main Menu",
1: "Tuxborn Installer",
2: "Feature Placeholder",
3: "Modlist Tasks Menu",
4: "Install Modlist Screen",
5: "Configure New Modlist",
6: "Configure Existing Modlist"
}
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
widget = self.stacked_widget.widget(index)
widget_class = widget.__class__.__name__ if widget else "None"
# Only print screen change debug to stderr to avoid workflow log pollution
import sys
print(f"[DEBUG] Screen changed to Index {index}: {screen_name} (Widget: {widget_class})", file=sys.stderr)
# Additional debug for the install modlist screen
if index == 4:
print(f" Install Modlist Screen details:", file=sys.stderr)
print(f" - Widget type: {type(widget)}", file=sys.stderr)
print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr)
if hasattr(widget, 'windowTitle'):
print(f" - Window title: {widget.windowTitle()}", file=sys.stderr)
if hasattr(widget, 'layout'):
layout = widget.layout()
if layout:
print(f" - Layout type: {type(layout)}", file=sys.stderr)
print(f" - Layout children count: {layout.count()}", file=sys.stderr)
def _check_protontricks_on_startup(self):
"""Check for protontricks installation on startup"""
try:
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
print(f"Protontricks not found: {details}")
# Show error dialog
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
# User chose to exit
print("User chose to exit due to missing protontricks")
sys.exit(1)
else:
debug_print(f"Protontricks detected: {details}")
except Exception as e:
print(f"Error checking protontricks: {e}")
# Continue anyway - don't block startup on detection errors
def cleanup_processes(self):
"""Clean up any running processes before closing"""
try:
# Clean up GUI services
for service in self.gui_services.values():
if hasattr(service, 'cleanup'):
service.cleanup()
# Clean up screen processes
screens = [
self.modlist_tasks_screen, self.tuxborn_screen, self.install_modlist_screen,
self.configure_new_modlist_screen, self.configure_existing_modlist_screen
]
for screen in screens:
if hasattr(screen, 'cleanup_processes'):
screen.cleanup_processes()
elif hasattr(screen, 'cleanup'):
screen.cleanup()
# Final safety net: kill any remaining jackify-engine processes
try:
import subprocess
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
except Exception:
pass # pkill might fail if no processes found, which is fine
except Exception as e:
print(f"Error during cleanup: {e}")
def closeEvent(self, event):
"""Handle window close event"""
self.cleanup_processes()
event.accept()
def open_settings_dialog(self):
try:
dlg = SettingsDialog(self)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_settings_dialog: {e}")
import traceback
traceback.print_exc()
def resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path)
def main():
"""Main entry point for the GUI application"""
# Check for CLI mode argument
if len(sys.argv) > 1 and '--cli' in sys.argv:
# Launch CLI frontend instead of GUI
try:
from jackify.frontends.cli.__main__ import main as cli_main
print("CLI mode detected - switching to CLI frontend")
return cli_main()
except ImportError as e:
print(f"Error importing CLI frontend: {e}")
print("CLI mode not available. Falling back to GUI mode.")
# Load config and set debug mode if needed
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
# Command-line --debug always takes precedence
if '--debug' in sys.argv or '-d' in sys.argv:
debug_mode = True
import logging
if debug_mode:
logging.getLogger().setLevel(logging.DEBUG)
print("[Jackify] Debug mode enabled (from config or CLI)")
else:
logging.getLogger().setLevel(logging.WARNING)
dev_mode = '--dev' in sys.argv
# Launch GUI application
from PySide6.QtGui import QIcon
app = QApplication(sys.argv)
# Global cleanup function for signal handling
def emergency_cleanup():
debug_print("Cleanup: terminating jackify-engine processes")
try:
import subprocess
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
except Exception:
pass
# Set up signal handlers for graceful shutdown
import signal
def signal_handler(sig, frame):
print(f"Received signal {sig}, cleaning up...")
emergency_cleanup()
app.quit()
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # System shutdown
# Set the application icon
icon_path = resource_path('assets/JackifyLogo_256.png')
app.setWindowIcon(QIcon(icon_path))
window = JackifyMainWindow(dev_mode=dev_mode)
window.show()
# Ensure cleanup on exit
import atexit
atexit.register(emergency_cleanup)
return app.exec()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,21 @@
"""
GUI Screens Module
Contains all the GUI screen components for Jackify.
"""
from .main_menu import MainMenu
from .tuxborn_installer import TuxbornInstallerScreen
from .modlist_tasks import ModlistTasksScreen
from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen
from .configure_existing_modlist import ConfigureExistingModlistScreen
__all__ = [
'MainMenu',
'TuxbornInstallerScreen',
'ModlistTasksScreen',
'InstallModlistScreen',
'ConfigureNewModlistScreen',
'ConfigureExistingModlistScreen'
]

View File

@@ -0,0 +1,710 @@
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html
import os
import subprocess
import sys
import threading
import time
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
import traceback
import signal
from jackify.backend.core.modlist_operations import get_jackify_engine_path
from jackify.backend.handlers.subprocess_utils import ProcessManager
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
print(message)
class ConfigureExistingModlistScreen(QWidget):
steam_restart_finished = Signal(bool, str)
def __init__(self, stacked_widget=None, main_menu_index=0):
super().__init__()
debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.debug = DEBUG_BORDERS
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_Existing_Modlist_workflow.log')
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
# --- Detect Steam Deck ---
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
# Initialize services early
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
self.api_key_service = APIKeyService()
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
# --- Fetch shortcuts for ModOrganizer.exe using existing backend functionality ---
# Use existing discover_executable_shortcuts which already filters by protontricks availability
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
# --- UI Layout ---
main_overall_vbox = QVBoxLayout(self)
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# --- Header (title, description) ---
header_layout = QVBoxLayout()
header_layout.setSpacing(1) # Reduce spacing between title and description
title = QLabel("<b>Configure Existing Modlist</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
title.setAlignment(Qt.AlignHCenter)
title.setMaximumHeight(30) # Force compact height
header_layout.addWidget(title)
desc = QLabel(
"This screen allows you to configure an existing modlist in Jackify. "
"Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(40) # Force compact height for description
header_layout.addWidget(desc)
header_widget = QWidget()
header_widget.setLayout(header_layout)
header_widget.setMaximumHeight(75) # Match other screens
if self.debug:
header_widget.setStyleSheet("border: 2px solid pink;")
header_widget.setToolTip("HEADER_SECTION")
main_overall_vbox.addWidget(header_widget)
# --- Upper section: shortcut selector (left) + process monitor (right) ---
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
user_config_vbox = QVBoxLayout()
user_config_vbox.setAlignment(Qt.AlignTop)
# --- [Options] header (moved here for alignment) ---
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;")
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
user_config_vbox.addWidget(options_header)
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6) # Reduced from 8 to 6 for better readability
form_grid.setContentsMargins(0, 0, 0, 0)
# --- Shortcut selector ---
shortcut_label = QLabel("Select Modlist:")
self.shortcut_combo = QComboBox()
self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map = []
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Add refresh button next to dropdown
refresh_btn = QPushButton("")
refresh_btn.setToolTip("Refresh modlist list")
refresh_btn.setFixedSize(30, 30)
refresh_btn.clicked.connect(self.refresh_modlist_list)
# Create horizontal layout for dropdown and refresh button
shortcut_hbox = QHBoxLayout()
shortcut_hbox.addWidget(self.shortcut_combo)
shortcut_hbox.addWidget(refresh_btn)
shortcut_hbox.setSpacing(4)
shortcut_hbox.setStretch(0, 1) # Make dropdown expand
form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(shortcut_hbox, 0, 1)
# --- Info message under shortcut selector ---
info_label = QLabel("<span style='color:#aaa'>If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.</span>")
info_label.setWordWrap(True)
form_grid.addWidget(info_label, 1, 0, 1, 2)
# --- Resolution selector ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 2, 1)
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
is_steam_deck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steam_deck = True
except Exception:
pass
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
if "1280x800 (Steam Deck)" in combo_items:
self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)"))
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
form_section_widget.setMinimumHeight(160) # Reduced to match compact form
form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown
if self.debug:
form_section_widget.setStyleSheet("border: 2px solid blue;")
form_section_widget.setToolTip("FORM_SECTION")
user_config_vbox.addWidget(form_section_widget)
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.go_back)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(process_monitor_widget, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Remove spacing - console should expand to fill available space
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing
self.console.setMaximumHeight(1000) # Allow growth when space available
self.console.setFontFamily('monospace')
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons
console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space
console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container
console_and_buttons_widget.setLayout(console_and_buttons_layout)
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space
self.setLayout(main_overall_vbox)
self.process = None
self.log_timer = None
self.last_log_pos = 0
self.top_timer = QTimer(self)
self.top_timer.timeout.connect(self.update_top_panel)
self.top_timer.start(2000)
self.start_btn.clicked.connect(self.validate_and_start_configure)
self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
self._was_at_bottom = True
# Time tracking for workflow completion
self._workflow_start_time = None
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
super().resizeEvent(event)
self._adjust_console_for_form_priority()
def _adjust_console_for_form_priority(self):
"""Console now dynamically fills available space with stretch=1, no manual calculation needed"""
# The console automatically fills remaining space due to stretch=1 in the layout
# Remove any fixed height constraints to allow natural stretching
self.console.setMaximumHeight(16777215) # Reset to default maximum
self.console.setMinimumHeight(50) # Keep minimum height for usability
def _setup_scroll_tracking(self):
"""Set up scroll tracking for professional auto-scroll behavior"""
scrollbar = self.console.verticalScrollBar()
scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
def _on_scrollbar_pressed(self):
"""User started manually scrolling"""
self._user_manually_scrolled = True
def _on_scrollbar_released(self):
"""User finished manually scrolling"""
self._user_manually_scrolled = False
def _on_scrollbar_value_changed(self):
"""Track if user is at bottom of scroll area"""
scrollbar = self.console.verticalScrollBar()
# Use tolerance to account for rounding and rapid updates
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
# If user manually scrolls to bottom, reset manual scroll flag
if self._was_at_bottom and self._user_manually_scrolled:
# Small delay to allow user to scroll away if they want
from PySide6.QtCore import QTimer
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
def _reset_manual_scroll_if_at_bottom(self):
"""Reset manual scroll flag if user is still at bottom after delay"""
scrollbar = self.console.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 1:
self._user_manually_scrolled = False
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
self._write_to_log_file(text)
scrollbar = self.console.verticalScrollBar()
# Check if user was at bottom BEFORE adding text
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
# Add the text
self.console.append(text)
# Auto-scroll if user was at bottom and hasn't manually scrolled
# Re-check bottom state after text addition for better reliability
if (was_at_bottom and not self._user_manually_scrolled) or \
(not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2):
scrollbar.setValue(scrollbar.maximum())
# Ensure user can still manually scroll up during rapid updates
if scrollbar.value() == scrollbar.maximum():
self._was_at_bottom = True
def _write_to_log_file(self, message):
"""Write message to workflow log file with timestamp"""
try:
from datetime import datetime
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
f.write(f"[{timestamp}] {message}\n")
except Exception:
# Logging should never break the workflow
pass
def validate_and_start_configure(self):
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
from pathlib import Path
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Get selected shortcut
idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...'
from jackify.frontends.gui.services.message_service import MessageService
if idx < 0 or idx >= len(self.shortcut_map):
MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium")
return
shortcut = self.shortcut_map[idx]
modlist_name = shortcut.get('AppName', '')
install_dir = shortcut.get('StartDir', '')
if not modlist_name or not install_dir:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
return
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
else:
debug_print("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
debug_print("DEBUG: Saved resolution cleared")
# Start the workflow (no shortcut creation needed)
self.start_workflow(modlist_name, install_dir, resolution)
def start_workflow(self, modlist_name, install_dir, resolution):
"""Start the configuration workflow using backend service directly"""
try:
# Start time tracking
self._workflow_start_time = time.time()
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service
from PySide6.QtCore import QThread, Signal
class ConfigurationThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, resolution):
super().__init__()
self.modlist_name = modlist_name
self.install_dir = install_dir
self.resolution = resolution
def run(self):
try:
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
import os
# Initialize backend service
system_info = SystemInfo(is_steamdeck=False) # TODO: Detect Steam Deck
modlist_service = ModlistService(system_info)
# Create modlist context for existing modlist configuration
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=Path(self.install_dir).parent / 'Downloads', # Default
game_type='skyrim', # Default for now - TODO: detect from modlist
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
modlist_source='existing',
skip_confirmation=True
)
# For existing modlists, add resolution if specified
if self.resolution != "Leave unchanged":
modlist_context.resolution = self.resolution.split()[0]
# Define callbacks
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def manual_steps_callback(modlist_name, retry_count):
# Existing modlists shouldn't need manual steps, but handle gracefully
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
# Call the working configuration service method
self.progress_update.emit("Starting existing modlist configuration...")
# For existing modlists, call configure_modlist_post_steam directly
# since Steam setup and manual steps should already be done
success = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
if not success:
self.error_occurred.emit("Configuration failed - check logs for details")
except Exception as e:
import traceback
error_msg = f"Configuration error: {e}\n{traceback.format_exc()}"
self.error_occurred.emit(error_msg)
# Create and start the configuration thread
self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution)
self.config_thread.progress_update.connect(self._safe_append_text)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.start()
except Exception as e:
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion"""
if success:
# Calculate time taken
time_taken = self._calculate_time_taken()
# Show success dialog with celebration
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="configure_existing",
time_taken=time_taken,
game_name=getattr(self, '_current_game_name', None),
parent=self
)
success_dialog.show()
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.critical(self, "Configuration Failed",
f"Configuration failed: {message}", safety_level="medium")
def on_configuration_error(self, error_message):
"""Handle configuration error"""
self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
def show_manual_steps_dialog(self, extra_warning=""):
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
msg = (
f"<b>Manual Proton Setup Required for <span style='color:#3fd0ea'>{modlist_name}</span></b><br>"
"After Steam restarts, complete the following steps in Steam:<br>"
f"1. Locate the '<b>{modlist_name}</b>' entry in your Steam Library<br>"
"2. Right-click and select 'Properties'<br>"
"3. Switch to the 'Compatibility' tab<br>"
"4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'<br>"
"5. Select 'Proton - Experimental' from the dropdown menu<br>"
"6. Close the Properties window<br>"
f"7. Launch '<b>{modlist_name}</b>' from your Steam Library<br>"
"8. Wait for Wabbajack to download its files and fully load<br>"
"9. Once Wabbajack has fully loaded, CLOSE IT completely and return here<br>"
"<br>Once you have completed ALL the steps above, click OK to continue."
f"{extra_warning}"
)
reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium")
if reply == QMessageBox.Yes:
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.write(b'\n')
self.config_process.waitForBytesWritten(1000)
self._config_prompt_state = None
self._manual_steps_buffer = []
else:
# User clicked Cancel or closed the dialog - cancel the workflow
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
# Terminate the configuration process
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.terminate()
self.config_process.waitForFinished(2000)
# Reset button states
self.start_btn.setEnabled(True)
self.cancel_btn.setVisible(True)
def show_next_steps_dialog(self, message):
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
dlg = QDialog(self)
dlg.setWindowTitle("Next Steps")
dlg.setModal(True)
layout = QVBoxLayout(dlg)
label = QLabel(message)
label.setWordWrap(True)
layout.addWidget(label)
btn_row = QHBoxLayout()
btn_return = QPushButton("Return")
btn_exit = QPushButton("Exit")
btn_row.addWidget(btn_return)
btn_row.addWidget(btn_exit)
layout.addLayout(btn_row)
def on_return():
dlg.accept()
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0)
def on_exit():
QApplication.quit()
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()
def go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def update_top_panel(self):
try:
result = subprocess.run([
"ps", "-eo", "pcpu,pmem,comm,args"
], stdout=subprocess.PIPE, text=True, timeout=2)
lines = result.stdout.splitlines()
header = "CPU%\tMEM%\tCOMMAND"
filtered = [header]
process_rows = []
for line in lines[1:]:
line_lower = line.lower()
# Include jackify-engine and related heavy processes
heavy_processes = (
"jackify-engine" in line_lower or "7zz" in line_lower or
"compressonator" in line_lower or "wine" in line_lower or
"wine64" in line_lower or "protontricks" in line_lower
)
# Include Python processes running configure-modlist command
configure_processes = (
"python" in line_lower and "configure-modlist" in line_lower
)
# Include configuration threads that might be running
config_threads = (
hasattr(self, 'config_thread') and
self.config_thread and
self.config_thread.isRunning() and
("python" in line_lower or "jackify" in line_lower)
)
if (heavy_processes or configure_processes or config_threads) and "jackify-gui.py" not in line_lower:
cols = line.strip().split(None, 3)
if len(cols) >= 3:
process_rows.append(cols)
process_rows.sort(key=lambda x: float(x[0]), reverse=True)
for cols in process_rows:
filtered.append('\t'.join(cols))
if len(filtered) == 1:
filtered.append("[No Jackify-related processes found]")
self.process_monitor.setPlainText('\n'.join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def _on_steam_restart_finished(self, success, message):
pass
def refresh_modlist_list(self):
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts"""
try:
# Re-detect shortcuts using existing backend functionality
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
# Clear and repopulate the combo box
self.shortcut_combo.clear()
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Show feedback to user in UI only (don't write to log before workflow starts)
# Feedback is shown by the updated dropdown items
except Exception as e:
# Don't write to log file before workflow starts - just show error in UI
MessageService.warning(self, "Refresh Error", f"Failed to refresh modlist list: {e}", safety_level="low")
def _calculate_time_taken(self) -> str:
"""Calculate and format the time taken for the workflow"""
if self._workflow_start_time is None:
return "unknown time"
elapsed_seconds = time.time() - self._workflow_start_time
elapsed_minutes = int(elapsed_seconds // 60)
elapsed_seconds_remainder = int(elapsed_seconds % 60)
if elapsed_minutes > 0:
if elapsed_minutes == 1:
return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
debug_print("DEBUG: Terminating ConfigurationThread")
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except:
pass
self.config_thread.terminate()
self.config_thread.wait(2000) # Wait up to 2 seconds

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
"""
MainMenu screen for Jackify GUI (Refactored)
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from PySide6.QtGui import QPixmap, QFont
from PySide6.QtCore import Qt
import os
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
class MainMenu(QWidget):
def __init__(self, stacked_widget=None, dev_mode=False):
super().__init__()
self.stacked_widget = stacked_widget
self.dev_mode = dev_mode
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
layout.setContentsMargins(50, 50, 50, 50)
layout.setSpacing(20)
# Title
title = QLabel("<b>Jackify</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
layout.addWidget(title)
# Description
desc = QLabel(
"Manage your modlists with native Linux tools. "
"Choose from the options below to install, "
"configure, or manage modlists."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
layout.addWidget(desc)
# Separator
layout.addSpacing(16)
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
layout.addWidget(sep)
layout.addSpacing(16)
# Menu buttons
button_width = 400
button_height = 60
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Coming Soon...", "coming_soon", "More features coming soon!"),
]
if self.dev_mode:
MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools"))
MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools"))
MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application"))
for label, action_id, description in MENU_ITEMS:
# Main button
btn = QPushButton(label)
btn.setFixedSize(button_width, 50)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Button container with proper alignment
btn_container = QWidget()
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(4)
btn_layout.setAlignment(Qt.AlignHCenter)
btn_layout.addWidget(btn)
# Description label with proper alignment
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width) # Match button width for proper alignment
btn_layout.addWidget(desc_label)
btn_container.setLayout(btn_layout)
layout.addWidget(btn_container)
# Disclaimer
layout.addSpacing(20)
disclaimer = QLabel(DISCLAIMER_TEXT)
disclaimer.setWordWrap(True)
disclaimer.setAlignment(Qt.AlignCenter)
disclaimer.setStyleSheet("color: #666; font-size: 10px;")
disclaimer.setFixedWidth(button_width)
layout.addWidget(disclaimer, alignment=Qt.AlignHCenter)
self.setLayout(layout)
def menu_action(self, action_id):
if action_id == "exit_jackify":
from PySide6.QtWidgets import QApplication
QApplication.quit()
elif action_id == "coming_soon":
# Show a friendly message about upcoming features
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self)
msg.setWindowTitle("Coming Soon")
msg.setText("More features are coming in future releases!\n\nFor now, you can install and configure any modlist using the 'Modlist Tasks' button.")
msg.setIcon(QMessageBox.Information)
msg.exec()
elif action_id == "modlist_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "return_main_menu":
# This is the main menu, so do nothing
pass
elif self.stacked_widget:
self.stacked_widget.setCurrentIndex(2) # Placeholder for now

View File

@@ -0,0 +1,214 @@
"""
Migrated Modlist Tasks Screen
This is a migrated version of the original modlist tasks menu that uses backend services
directly instead of subprocess calls to jackify-cli.py.
Key changes:
- Uses backend services directly instead of subprocess.Popen()
- Direct backend service integration
- Maintains same UI and workflow
- Improved error handling and progress reporting
"""
import os
import sys
import logging
from pathlib import Path
from typing import List, Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGridLayout, QSizePolicy, QApplication, QFrame, QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
# Import our GUI services
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
# Constants
DEBUG_BORDERS = False
logger = logging.getLogger(__name__)
class ModlistTasksScreen(QWidget):
"""
Migrated Modlist Tasks screen that uses backend services directly.
This replaces the original ModlistTasksMenu's subprocess calls with
direct navigation to existing automated workflows.
"""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None, dev_mode=False):
super().__init__()
logger.info("ModlistTasksScreen initializing (migrated version)")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.debug = DEBUG_BORDERS
self.dev_mode = dev_mode
# Initialize backend services
if system_info is None:
system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
self.system_info = system_info
# Setup UI
self._setup_ui()
logger.info("ModlistTasksScreen initialized (migrated version)")
def _is_steamdeck(self) -> bool:
"""Check if running on Steam Deck"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
return True
return False
except Exception:
return False
def _setup_ui(self):
"""Set up the user interface"""
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(50, 50, 50, 50)
if self.debug:
self.setStyleSheet("border: 2px solid green;")
# Header section
self._setup_header(main_layout)
# Menu buttons section
self._setup_menu_buttons(main_layout)
# Bottom navigation
self._setup_navigation(main_layout)
def _setup_header(self, layout):
"""Set up the header section"""
header_layout = QVBoxLayout()
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Modlist Tasks</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
desc = QLabel(
"Manage your modlists with native Linux tools. Choose "
"from the options below to install or configure modlists.<br>&nbsp;"
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(desc)
header_layout.addSpacing(24)
# Separator
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options
MENU_ITEMS = [
("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"),
("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"),
("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"),
]
if self.dev_mode:
MENU_ITEMS.append(("Install Wabbajack Application", "install_wabbajack", "Set up the Wabbajack application"))
MENU_ITEMS.append(("Return to Main Menu", "return_main_menu", "Go back to the main menu"))
# Create grid layout for buttons
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
btn = QPushButton(label)
btn.setFixedSize(button_width, button_height)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Create description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
# Add to grid
button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter)
button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter)
layout.addLayout(button_grid)
def _setup_navigation(self, layout):
"""Set up the navigation section"""
# Remove the bottom navigation bar entirely (no gray Back to Main Menu button)
pass
def menu_action(self, action_id):
"""Handle menu button clicks"""
logger.info(f"Modlist tasks menu action: {action_id}")
if not self.stacked_widget:
return
# Navigate to different screens based on action
if action_id == "return_main_menu":
self.stacked_widget.setCurrentIndex(0)
elif action_id == "install_modlist":
self.stacked_widget.setCurrentIndex(4)
elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(5)
elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(6)
def go_back(self):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup(self):
"""Clean up resources when the screen is closed"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
"""
GUI Services for Jackify Frontend
Service layer that provides GUI-friendly interfaces to backend services,
including progress callbacks, error handling, and Qt signal integration.
"""
__all__ = []

View File

@@ -0,0 +1,287 @@
"""
Non-Focus-Stealing Message Service for Jackify
Provides message boxes that don't steal focus from the current application
"""
import random
import string
from typing import Optional
from PySide6.QtWidgets import QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QCheckBox
from PySide6.QtCore import Qt, QTimer
class NonFocusMessageBox(QMessageBox):
"""Custom QMessageBox that prevents focus stealing"""
def __init__(self, parent=None, critical=False, safety_level="low"):
super().__init__(parent)
self.safety_level = safety_level
self._setup_no_focus_attributes(critical, safety_level)
def _setup_no_focus_attributes(self, critical, safety_level):
"""Configure the message box to not steal focus"""
# Set modality based on criticality and safety level
if critical or safety_level == "high":
self.setWindowModality(Qt.ApplicationModal)
elif safety_level == "medium":
self.setWindowModality(Qt.NonModal)
else:
self.setWindowModality(Qt.NonModal)
# Prevent focus stealing
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setWindowFlags(
self.windowFlags() |
Qt.WindowStaysOnTopHint |
Qt.WindowDoesNotAcceptFocus
)
# Set focus policy to prevent taking focus
self.setFocusPolicy(Qt.NoFocus)
# Make sure child widgets don't steal focus either
for child in self.findChildren(QWidget):
child.setFocusPolicy(Qt.NoFocus)
def showEvent(self, event):
"""Override to ensure no focus stealing on show"""
super().showEvent(event)
# Ensure we don't steal focus
self.activateWindow()
self.raise_()
class SafeMessageBox(NonFocusMessageBox):
"""Enhanced message box with safety features"""
def __init__(self, parent=None, safety_level="low"):
super().__init__(parent, critical=(safety_level == "high"), safety_level=safety_level)
self.safety_level = safety_level
self.countdown_remaining = 0
self.confirmation_code = None
self.countdown_timer = None
self.code_input = None
self.understanding_checkbox = None
def setup_safety_features(self, title: str, message: str,
danger_action: str = "OK",
safe_action: str = "Cancel",
is_question: bool = False):
self.setWindowTitle(title)
self.setText(message)
if self.safety_level == "high":
self.setIcon(QMessageBox.Warning)
self._setup_high_safety(danger_action, safe_action)
elif self.safety_level == "medium":
self.setIcon(QMessageBox.Information)
self._setup_medium_safety(danger_action, safe_action)
else:
self.setIcon(QMessageBox.Information)
self._setup_low_safety(danger_action, safe_action)
# --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons ---
if is_question and hasattr(self, 'proceed_btn'):
self.proceed_btn.setText(danger_action)
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
self.proceed_btn.clicked.disconnect()
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
self.cancel_btn.setText(safe_action)
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
self.cancel_btn.clicked.disconnect()
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
def _setup_high_safety(self, danger_action: str, safe_action: str):
"""High safety: requires typing confirmation code"""
# Generate random confirmation code
self.confirmation_code = ''.join(random.choices(string.ascii_uppercase, k=6))
# Create custom buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make cancel the default (Enter key)
self.setDefaultButton(self.cancel_btn)
# Initially disable proceed button
self.proceed_btn.setEnabled(False)
# Add confirmation code input
widget = QWidget()
layout = QVBoxLayout(widget)
instruction = QLabel(f"Type '{self.confirmation_code}' to confirm:")
instruction.setStyleSheet("font-weight: bold; color: red;")
layout.addWidget(instruction)
self.code_input = QLineEdit()
self.code_input.setPlaceholderText("Enter confirmation code...")
self.code_input.textChanged.connect(self._check_code_input)
layout.addWidget(self.code_input)
self.layout().addWidget(widget, 1, 0, 1, self.layout().columnCount())
# Start countdown
self._start_countdown(3)
def _setup_medium_safety(self, danger_action: str, safe_action: str):
"""Medium safety: requires wait period"""
# Create custom buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make cancel the default (Enter key)
self.setDefaultButton(self.cancel_btn)
# Initially disable proceed button
self.proceed_btn.setEnabled(False)
# Start countdown
self._start_countdown(3)
def _setup_low_safety(self, danger_action: str, safe_action: str):
"""Low safety: no additional features needed"""
# Create standard buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make proceed the default for low safety
self.setDefaultButton(self.proceed_btn)
def _start_countdown(self, seconds: int):
self.countdown_timer = QTimer()
self.countdown_timer.timeout.connect(self._update_countdown)
self.countdown_remaining = seconds
self._update_countdown()
self.countdown_timer.start(1000) # Update every second
def _update_countdown(self):
if self.countdown_remaining > 0:
if hasattr(self, 'proceed_btn'):
if self.safety_level == "high":
self.proceed_btn.setText(f"Please wait {self.countdown_remaining}s...")
else:
self.proceed_btn.setText(f"OK ({self.countdown_remaining}s)")
self.proceed_btn.setEnabled(False)
if hasattr(self, 'cancel_btn'):
self.cancel_btn.setEnabled(False)
self.countdown_remaining -= 1
else:
self.countdown_timer.stop()
if hasattr(self, 'proceed_btn'):
if self.safety_level == "high":
self.proceed_btn.setText("Proceed")
else:
self.proceed_btn.setText("OK")
self.proceed_btn.setEnabled(True)
if hasattr(self, 'cancel_btn'):
self.cancel_btn.setEnabled(True)
self._check_all_requirements()
def _check_code_input(self):
"""Check if typed code matches"""
if self.countdown_remaining <= 0:
self._check_all_requirements()
def _check_all_requirements(self):
"""Check if all requirements are met"""
can_proceed = self.countdown_remaining <= 0
if self.safety_level == "high":
can_proceed = can_proceed and (
self.code_input.text().upper() == self.confirmation_code
)
self.proceed_btn.setEnabled(can_proceed)
class MessageService:
"""Service class for creating non-focus-stealing message boxes"""
@staticmethod
def _create_base_message_box(parent: Optional[QWidget] = None, critical: bool = False, safety_level: str = "low") -> NonFocusMessageBox:
"""Create a base message box with no focus stealing"""
if safety_level in ["medium", "high"]:
return SafeMessageBox(parent, safety_level)
else:
return NonFocusMessageBox(parent, critical)
@staticmethod
def information(parent: Optional[QWidget] = None,
title: str = "Information",
message: str = "",
buttons: QMessageBox.StandardButtons = QMessageBox.Ok,
default_button: QMessageBox.StandardButton = QMessageBox.Ok,
critical: bool = False,
safety_level: str = "low") -> int:
"""Show information message without stealing focus"""
if safety_level in ["medium", "high"]:
msg_box = SafeMessageBox(parent, safety_level)
msg_box.setup_safety_features(title, message, "OK", "Cancel")
else:
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)
return msg_box.exec()
@staticmethod
def warning(parent: Optional[QWidget] = None,
title: str = "Warning",
message: str = "",
buttons: QMessageBox.StandardButtons = QMessageBox.Ok,
default_button: QMessageBox.StandardButton = QMessageBox.Ok,
critical: bool = False,
safety_level: str = "low") -> int:
"""Show warning message without stealing focus"""
if safety_level in ["medium", "high"]:
msg_box = SafeMessageBox(parent, safety_level)
msg_box.setup_safety_features(title, message, "OK", "Cancel")
else:
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)
return msg_box.exec()
@staticmethod
def critical(parent: Optional[QWidget] = None,
title: str = "Critical Error",
message: str = "",
buttons: QMessageBox.StandardButtons = QMessageBox.Ok,
default_button: QMessageBox.StandardButton = QMessageBox.Ok,
safety_level: str = "medium") -> int:
"""Show critical error message (always requires attention)"""
msg_box = MessageService._create_base_message_box(parent, critical=True, safety_level=safety_level)
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)
return msg_box.exec()
@staticmethod
def question(parent: Optional[QWidget] = None,
title: str = "Question",
message: str = "",
buttons: QMessageBox.StandardButtons = QMessageBox.Yes | QMessageBox.No,
default_button: QMessageBox.StandardButton = QMessageBox.No,
critical: bool = False,
safety_level: str = "low") -> int:
"""Show question dialog without stealing focus"""
if safety_level in ["medium", "high"]:
msg_box = SafeMessageBox(parent, safety_level)
msg_box.setup_safety_features(title, message, "Yes", "No", is_question=True)
else:
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)
return msg_box.exec()

View File

@@ -0,0 +1,15 @@
"""
Jackify GUI theme and shared constants
"""
import os
JACKIFY_COLOR_BLUE = "#3fd0ea" # Official Jackify blue
DEBUG_BORDERS = False
ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets')
LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png')
DISCLAIMER_TEXT = (
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
"Please back up your data and use caution."
)

View File

@@ -0,0 +1,38 @@
"""
GUI Utilities for Jackify Frontend
"""
import re
ANSI_COLOR_MAP = {
'30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white',
'90': 'gray', '91': 'lightcoral', '92': 'lightgreen', '93': 'khaki', '94': 'lightblue', '95': 'violet', '96': 'lightcyan', '97': 'white'
}
ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m')
def ansi_to_html(text):
"""Convert ANSI color codes to HTML"""
result = ''
last_end = 0
color = None
for match in ANSI_RE.finditer(text):
start, end = match.span()
code = match.group(1)
if start > last_end:
chunk = text[last_end:start]
if color:
result += f'<span style="color:{color}">{chunk}</span>'
else:
result += chunk
if code == '0':
color = None
elif code in ANSI_COLOR_MAP:
color = ANSI_COLOR_MAP[code]
last_end = end
if last_end < len(text):
chunk = text[last_end:]
if color:
result += f'<span style="color:{color}">{chunk}</span>'
else:
result += chunk
result = result.replace('\n', '<br>')
return result

View File

@@ -0,0 +1,201 @@
"""
Unsupported Game Dialog Widget
This module provides a popup dialog to warn users when they're about to install
a modlist for a game that doesn't support automated post-install configuration.
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTextEdit, QFrame
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QPixmap, QIcon
class UnsupportedGameDialog(QDialog):
"""
Dialog to warn users about unsupported games for post-install configuration.
This dialog informs users that while any modlist can be downloaded with Jackify,
only certain games support automated post-install configuration.
"""
# Signal emitted when user clicks OK to continue
continue_installation = Signal()
def __init__(self, parent=None, game_name: str = None):
super().__init__(parent)
self.game_name = game_name
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("Game Support Notice")
self.setModal(True)
self.setFixedSize(500, 500)
# Main layout
layout = QVBoxLayout()
layout.setSpacing(20)
layout.setContentsMargins(20, 20, 20, 20)
# Icon and title (smaller, less vertical space)
title_layout = QHBoxLayout()
icon_label = QLabel("!")
icon_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon_label.setFixedSize(32, 32)
icon_label.setStyleSheet("color: #e67e22;")
title_layout.addWidget(icon_label)
title_label = QLabel("<b>Game Support Notice</b>")
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
title_label.setStyleSheet("color: #3fd0ea;")
title_layout.addWidget(title_label)
title_layout.addStretch()
layout.addLayout(title_layout)
# Reduce space after title
layout.addSpacing(4)
# Separator line
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
separator.setStyleSheet("background: #444; max-height: 1px;")
layout.addWidget(separator)
# Reduce space after separator
layout.addSpacing(4)
# Message text
message_text = QTextEdit()
message_text.setReadOnly(True)
message_text.setMaximumHeight(340)
message_text.setStyleSheet("""
QTextEdit {
background-color: #23272e;
color: #f8f9fa;
border: 1px solid #444;
border-radius: 6px;
padding: 12px;
font-size: 12px;
font-family: 'Segoe UI', Arial, sans-serif;
}
""")
# Create the message content
if self.game_name:
message = f"""<p><strong>You are about to install a modlist for <em>{self.game_name}</em>.</strong></p>
<p>While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:</p>
<ul>
<li><strong>Skyrim Special Edition</strong></li>
<li><strong>Fallout 4</strong></li>
<li><strong>Fallout New Vegas</strong></li>
<li><strong>Oblivion</strong></li>
<li><strong>Starfield</strong></li>
<li><strong>Oblivion Remastered</strong></li>
</ul>
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
<p><em>We are working to add more automated support in future releases!</em></p>
<p>Click <strong>Continue</strong> to proceed with the modlist installation, or <strong>Cancel</strong> to go back.</p>"""
else:
message = f"""<p><strong>You are about to install a modlist for an unsupported game.</strong></p>
<p>While any modlist can be downloaded with Jackify, the post-install configuration can only be automatically applied to:</p>
<ul>
<li><strong>Skyrim Special Edition</strong></li>
<li><strong>Fallout 4</strong></li>
<li><strong>Fallout New Vegas</strong></li>
<li><strong>Oblivion</strong></li>
<li><strong>Starfield</strong></li>
<li><strong>Oblivion Remastered</strong></li>
</ul>
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
<p><em>We are working to add more automated support in future releases!</em></p>
<p>Click <strong>Continue</strong> to proceed with the modlist installation, or <strong>Cancel</strong> to go back.</p>"""
message_text.setHtml(message)
layout.addWidget(message_text)
# Button layout (Continue left, Cancel right)
button_layout = QHBoxLayout()
button_layout.addStretch()
continue_button = QPushButton("Continue")
continue_button.setFixedSize(100, 35)
continue_button.setDefault(True)
continue_button.setStyleSheet("""
QPushButton {
background-color: #3fd0ea;
color: #23272e;
border: none;
border-radius: 5px;
font-weight: bold;
}
QPushButton:hover {
background-color: #2bb8d6;
}
QPushButton:pressed {
background-color: #1a7e99;
}
""")
button_layout.addWidget(continue_button)
cancel_button = QPushButton("Cancel")
cancel_button.setFixedSize(100, 35)
cancel_button.setStyleSheet("""
QPushButton {
background-color: #6c757d;
color: white;
border: none;
border-radius: 5px;
font-weight: bold;
}
QPushButton:hover {
background-color: #5a6268;
}
QPushButton:pressed {
background-color: #545b62;
}
""")
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
self.cancel_button = cancel_button
self.continue_button = continue_button
self.setStyleSheet("""
QDialog {
background-color: #23272e;
color: #f8f9fa;
}
QLabel {
color: #f8f9fa;
}
""")
def setup_connections(self):
"""Set up signal connections."""
self.cancel_button.clicked.connect(self.reject)
self.continue_button.clicked.connect(self.accept)
self.accepted.connect(self.continue_installation.emit)
@staticmethod
def show_dialog(parent=None, game_name: str = None) -> bool:
"""
Show the unsupported game dialog and return the user's choice.
Args:
parent: Parent widget
game_name: Name of the unsupported game (optional)
Returns:
True if user clicked Continue, False if Cancel
"""
dialog = UnsupportedGameDialog(parent, game_name)
result = dialog.exec()
return result == QDialog.DialogCode.Accepted