mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
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:
5
jackify/frontends/__init__.py
Normal file
5
jackify/frontends/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Jackify Frontends
|
||||
|
||||
User interface layers for CLI and GUI.
|
||||
"""
|
||||
5
jackify/frontends/cli/__init__.py
Normal file
5
jackify/frontends/cli/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Jackify CLI Frontend
|
||||
|
||||
Command-line interface for Jackify that uses the backend services.
|
||||
"""
|
||||
45
jackify/frontends/cli/__main__.py
Normal file
45
jackify/frontends/cli/__main__.py
Normal 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()
|
||||
5
jackify/frontends/cli/commands/__init__.py
Normal file
5
jackify/frontends/cli/commands/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
CLI Commands
|
||||
|
||||
Individual command implementations for the CLI interface.
|
||||
"""
|
||||
159
jackify/frontends/cli/commands/configure_modlist.py
Normal file
159
jackify/frontends/cli/commands/configure_modlist.py
Normal 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
|
||||
363
jackify/frontends/cli/commands/install_modlist.py
Normal file
363
jackify/frontends/cli/commands/install_modlist.py
Normal 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
|
||||
247
jackify/frontends/cli/commands/tuxborn.py
Normal file
247
jackify/frontends/cli/commands/tuxborn.py
Normal 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
441
jackify/frontends/cli/main.py
Executable 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)
|
||||
20
jackify/frontends/cli/menus/__init__.py
Normal file
20
jackify/frontends/cli/menus/__init__.py
Normal 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'
|
||||
]
|
||||
73
jackify/frontends/cli/menus/additional_menu.py
Normal file
73
jackify/frontends/cli/menus/additional_menu.py
Normal 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)
|
||||
32
jackify/frontends/cli/menus/hoolamike_menu.py
Normal file
32
jackify/frontends/cli/menus/hoolamike_menu.py
Normal 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}")
|
||||
74
jackify/frontends/cli/menus/main_menu.py
Normal file
74
jackify/frontends/cli/menus/main_menu.py
Normal 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
|
||||
174
jackify/frontends/cli/menus/recovery_menu.py
Normal file
174
jackify/frontends/cli/menus/recovery_menu.py
Normal 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
|
||||
194
jackify/frontends/cli/menus/tuxborn_menu.py
Normal file
194
jackify/frontends/cli/menus/tuxborn_menu.py
Normal 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)
|
||||
115
jackify/frontends/cli/menus/wabbajack_menu.py
Normal file
115
jackify/frontends/cli/menus/wabbajack_menu.py
Normal 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()
|
||||
9
jackify/frontends/cli/ui/__init__.py
Normal file
9
jackify/frontends/cli/ui/__init__.py
Normal 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__ = []
|
||||
6
jackify/frontends/gui/__init__.py
Normal file
6
jackify/frontends/gui/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
GUI Frontend for Jackify
|
||||
PyQt-based graphical user interface that uses backend services directly
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
11
jackify/frontends/gui/__main__.py
Normal file
11
jackify/frontends/gui/__main__.py
Normal 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()
|
||||
10
jackify/frontends/gui/dialogs/__init__.py
Normal file
10
jackify/frontends/gui/dialogs/__init__.py
Normal 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']
|
||||
200
jackify/frontends/gui/dialogs/completion_dialog.py
Normal file
200
jackify/frontends/gui/dialogs/completion_dialog.py
Normal 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
|
||||
328
jackify/frontends/gui/dialogs/protontricks_error_dialog.py
Normal file
328
jackify/frontends/gui/dialogs/protontricks_error_dialog.py
Normal 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()
|
||||
239
jackify/frontends/gui/dialogs/success_dialog.py
Normal file
239
jackify/frontends/gui/dialogs/success_dialog.py
Normal 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()
|
||||
529
jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py
Normal file
529
jackify/frontends/gui/dialogs/ulimit_guidance_dialog.py
Normal 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)
|
||||
188
jackify/frontends/gui/dialogs/warning_dialog.py
Normal file
188
jackify/frontends/gui/dialogs/warning_dialog.py
Normal 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; }")
|
||||
819
jackify/frontends/gui/main.py
Normal file
819
jackify/frontends/gui/main.py
Normal 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())
|
||||
21
jackify/frontends/gui/screens/__init__.py
Normal file
21
jackify/frontends/gui/screens/__init__.py
Normal 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'
|
||||
]
|
||||
710
jackify/frontends/gui/screens/configure_existing_modlist.py
Normal file
710
jackify/frontends/gui/screens/configure_existing_modlist.py
Normal 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
|
||||
1223
jackify/frontends/gui/screens/configure_new_modlist.py
Normal file
1223
jackify/frontends/gui/screens/configure_new_modlist.py
Normal file
File diff suppressed because it is too large
Load Diff
2528
jackify/frontends/gui/screens/install_modlist.py
Normal file
2528
jackify/frontends/gui/screens/install_modlist.py
Normal file
File diff suppressed because it is too large
Load Diff
128
jackify/frontends/gui/screens/main_menu.py
Normal file
128
jackify/frontends/gui/screens/main_menu.py
Normal 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
|
||||
214
jackify/frontends/gui/screens/modlist_tasks.py
Normal file
214
jackify/frontends/gui/screens/modlist_tasks.py
Normal 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> "
|
||||
)
|
||||
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
|
||||
1829
jackify/frontends/gui/screens/tuxborn_installer.py
Normal file
1829
jackify/frontends/gui/screens/tuxborn_installer.py
Normal file
File diff suppressed because it is too large
Load Diff
8
jackify/frontends/gui/services/__init__.py
Normal file
8
jackify/frontends/gui/services/__init__.py
Normal 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__ = []
|
||||
287
jackify/frontends/gui/services/message_service.py
Normal file
287
jackify/frontends/gui/services/message_service.py
Normal 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()
|
||||
15
jackify/frontends/gui/shared_theme.py
Normal file
15
jackify/frontends/gui/shared_theme.py
Normal 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."
|
||||
)
|
||||
38
jackify/frontends/gui/utils.py
Normal file
38
jackify/frontends/gui/utils.py
Normal 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
|
||||
201
jackify/frontends/gui/widgets/unsupported_game_dialog.py
Normal file
201
jackify/frontends/gui/widgets/unsupported_game_dialog.py
Normal 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
|
||||
Reference in New Issue
Block a user