mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -45,6 +45,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
return
|
||||
ConfigHandler._initialized = True
|
||||
|
||||
self._dirty_keys: set = set()
|
||||
|
||||
self.config_dir = os.path.expanduser("~/.config/jackify")
|
||||
self.config_file = os.path.join(self.config_dir, "config.json")
|
||||
self.settings = {
|
||||
@@ -146,7 +148,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
logger.info(f"Migrating config from {current_version} to {target_version}")
|
||||
|
||||
# Migration: v0.0.x -> v0.2.0
|
||||
# Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM)
|
||||
# Encryption changed from cryptography (Fernet) to pycryptodomex (AES-GCM)
|
||||
# Old encrypted API keys cannot be decrypted, must be re-entered
|
||||
from packaging import version
|
||||
if version.parse(current_version) < version.parse("0.2.0"):
|
||||
@@ -219,14 +221,34 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
logger.error(f"Error creating configuration directory: {e}")
|
||||
|
||||
def save_config(self):
|
||||
"""Save current configuration to file"""
|
||||
"""Save current configuration to file.
|
||||
|
||||
Reads disk state, overlays only dirty keys, then writes atomically.
|
||||
This prevents concurrent callers from clobbering each other's saves.
|
||||
"""
|
||||
import tempfile
|
||||
try:
|
||||
self._create_config_dir()
|
||||
# When dirty keys exist, read disk and overlay only those keys so concurrent
|
||||
# callers don't clobber each other. When no keys are dirty (migration/bootstrap
|
||||
# code that mutates self.settings directly), fall back to writing all settings.
|
||||
if self._dirty_keys:
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r') as f:
|
||||
on_disk = json.load(f)
|
||||
else:
|
||||
on_disk = self.settings.copy()
|
||||
except Exception:
|
||||
on_disk = self.settings.copy()
|
||||
for key in self._dirty_keys:
|
||||
on_disk[key] = self.settings[key]
|
||||
else:
|
||||
on_disk = self.settings.copy()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
json.dump(on_disk, f, indent=2)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_file)
|
||||
except Exception:
|
||||
@@ -235,6 +257,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
self._dirty_keys.clear()
|
||||
logger.debug("Saved configuration to file")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -242,35 +265,38 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
return False
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
Get a configuration value by key.
|
||||
Always reads fresh from disk to avoid stale data.
|
||||
"""
|
||||
"""Return configuration value, preferring any unsaved in-memory value."""
|
||||
if key in self._dirty_keys:
|
||||
return self.settings.get(key, default)
|
||||
config = self._read_config_from_disk()
|
||||
return config.get(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
"""Set a configuration value"""
|
||||
"""Set a configuration value (marks key dirty until save_config is called)."""
|
||||
self.settings[key] = value
|
||||
self._dirty_keys.add(key)
|
||||
return True
|
||||
|
||||
|
||||
def update(self, settings_dict):
|
||||
"""Update multiple configuration values"""
|
||||
"""Update multiple configuration values (marks all keys dirty)."""
|
||||
self.settings.update(settings_dict)
|
||||
self._dirty_keys.update(settings_dict.keys())
|
||||
return True
|
||||
|
||||
def add_steam_library(self, path):
|
||||
"""Add a Steam library path to configuration"""
|
||||
if path not in self.settings["steam_libraries"]:
|
||||
self.settings["steam_libraries"].append(path)
|
||||
self._dirty_keys.add("steam_libraries")
|
||||
logger.debug(f"Added Steam library: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_steam_library(self, path):
|
||||
"""Remove a Steam library path from configuration"""
|
||||
if path in self.settings["steam_libraries"]:
|
||||
self.settings["steam_libraries"].remove(path)
|
||||
self._dirty_keys.add("steam_libraries")
|
||||
logger.debug(f"Removed Steam library: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2,88 +2,24 @@
|
||||
Config handler API key encryption and storage.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jackify.backend.utils.machine_crypto import encrypt as _encrypt, decrypt as _decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigEncryptionMixin:
|
||||
"""Mixin providing encryption and API key storage for ConfigHandler."""
|
||||
|
||||
def _get_encryption_key(self) -> bytes:
|
||||
"""Generate Fernet-compatible encryption key for API key storage."""
|
||||
import socket
|
||||
import getpass
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
machine_id = None
|
||||
try:
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except Exception:
|
||||
try:
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify" if machine_id else f"{hostname}:{username}:jackify"
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get machine info for encryption: %s", e)
|
||||
key_material = "jackify:default:key"
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_api_key(self, api_key: str) -> str:
|
||||
"""Encrypt API key using AES-GCM."""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
nonce = get_random_bytes(12)
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
except ImportError:
|
||||
logger.warning("pycryptodome not available, using base64 encoding (less secure)")
|
||||
return base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error("Error encrypting API key: %s", e)
|
||||
return ""
|
||||
"""Encrypt API key using AES-GCM via machine_crypto."""
|
||||
return _encrypt(api_key)
|
||||
|
||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
||||
"""Decrypt API key using AES-GCM."""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
if not hasattr(AES, 'MODE_GCM'):
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
combined = base64.b64decode(encrypted_key.encode('utf-8'))
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
ciphertext = combined[12:-16]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return plaintext.decode('utf-8')
|
||||
except ImportError:
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
except (AttributeError, Exception):
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error("Error decrypting API key: %s", e)
|
||||
return None
|
||||
"""Decrypt API key. Returns None on any failure - never returns garbage."""
|
||||
return _decrypt(encrypted_key)
|
||||
|
||||
def save_api_key(self, api_key):
|
||||
"""Save Nexus API key with encryption."""
|
||||
|
||||
@@ -20,13 +20,13 @@ class FilesystemOwnershipMixin:
|
||||
def all_owned_by_user(path: Path) -> bool:
|
||||
"""Return True if all files and directories under path are owned by the current user."""
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
gids = set([os.getgid()] + os.getgroups())
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full_path = os.path.join(root, name)
|
||||
try:
|
||||
stat = os.stat(full_path)
|
||||
if stat.st_uid != uid or stat.st_gid != gid:
|
||||
if stat.st_uid != uid or stat.st_gid not in gids:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -50,7 +50,7 @@ class FilesystemOwnershipMixin:
|
||||
if not FilesystemOwnershipMixin.all_owned_by_user(path):
|
||||
try:
|
||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
||||
group_name = grp.getgrgid(os.geteuid()).gr_name
|
||||
group_name = grp.getgrgid(os.getgid()).gr_name
|
||||
except KeyError:
|
||||
logger.error("Could not determine current user or group name.")
|
||||
return False, "Could not determine current user or group name."
|
||||
@@ -68,6 +68,9 @@ class FilesystemOwnershipMixin:
|
||||
return False, error_msg
|
||||
|
||||
logger.info("Files in %s are owned by current user, verifying permissions...", path)
|
||||
if FilesystemOwnershipMixin._perms_already_ok(path):
|
||||
logger.info("Permissions already correct for %s, skipping chmod", path)
|
||||
return True, ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['chmod', '-R', '755', str(path)],
|
||||
@@ -84,6 +87,19 @@ class FilesystemOwnershipMixin:
|
||||
logger.warning("Error running chmod: %s, continuing anyway", e)
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _perms_already_ok(path: Path) -> bool:
|
||||
"""Return True if every entry under path already has mode 0o755."""
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full = os.path.join(root, name)
|
||||
try:
|
||||
if os.stat(full).st_mode & 0o777 != 0o755:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import glob # Add for the simpler tab completion
|
||||
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_DISABLED, COLOR_ACTION, COLOR_INPUT
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
import glob
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
READLINE_AVAILABLE = False
|
||||
READLINE_HAS_PROMPT = False
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
|
||||
from .resolution_handler import ResolutionHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -596,7 +596,9 @@ class ModlistConfigurationMixin:
|
||||
# dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only
|
||||
# verified working on win10 + global-native, so we keep that state and skip
|
||||
# the dotnet9/win11 step. Whether Synthesis runs under global-native on an NSF
|
||||
# prefix is untested - revisit if a modlist needs live Synthesis.
|
||||
# prefix has not been specifically tested - revisit if a modlist needs live
|
||||
# Synthesis. The NuGet cert Synthesis needs is applied regardless (see
|
||||
# apply_tool_config), since it doesn't touch mscoree or Windows version.
|
||||
_nsf = getattr(self, '_nsf_detected', False)
|
||||
apply_tool_config(
|
||||
compatdata_path,
|
||||
|
||||
@@ -27,7 +27,7 @@ from .modlist_configuration import ModlistConfigurationMixin
|
||||
from .modlist_wine_ops import ModlistWineOpsMixin
|
||||
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
|
||||
|
||||
# Standard logging (no file handler)
|
||||
import logging
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional, Dict, List, Any, Union
|
||||
from .protontricks_handler import ProtontricksHandler
|
||||
from .shortcut_handler import ShortcutHandler
|
||||
from .menu_handler import MenuHandler, ModlistMenuHandler
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
# Standard logging (no file handler) - LoggingHandler import removed
|
||||
import re
|
||||
import subprocess
|
||||
@@ -20,7 +20,7 @@ import time
|
||||
import pty
|
||||
|
||||
# Import UI Colors first - these should always be available
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING
|
||||
|
||||
# Standard logging (no file handler) - LoggingHandler import removed
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from .config_handler import ConfigHandler
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import signal
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -371,29 +371,89 @@ class NativeComponentInstaller:
|
||||
return False
|
||||
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
# x86 inner cab is 'a10', x64 inner cab is 'a12'
|
||||
|
||||
# DLL overrides must be written before the installer runs so Wine picks them up
|
||||
self._apply_dll_overrides()
|
||||
|
||||
env = self._wine_env_base()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86),
|
||||
(x64, 'a12', system32, _VCRUN2022_DLLS_X64)]:
|
||||
arch_tmp = Path(tmpdir) / cab
|
||||
arch_tmp.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True)
|
||||
inner_cab = arch_tmp / cab
|
||||
if not inner_cab.is_file():
|
||||
self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name)
|
||||
return False
|
||||
for dll_name in dlls:
|
||||
subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True)
|
||||
if not (dest / 'msvcp140.dll').is_file():
|
||||
self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest)
|
||||
return False
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# x86: pre-extract msvcp140.dll before running the installer.
|
||||
# Wine's builtin msvcp140 reports a higher version number so the installer
|
||||
# skips it without this manual extraction (Wine bug #57518).
|
||||
win32_tmp = tmpdir_path / 'win32'
|
||||
win32_tmp.mkdir()
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(win32_tmp), '-F', 'a10', str(x86)],
|
||||
capture_output=True,
|
||||
)
|
||||
inner_x86 = win32_tmp / 'a10'
|
||||
if inner_x86.is_file():
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(syswow64), '-F', 'msvcp140.dll', str(inner_x86)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.logger.warning("vcrun2022: x86 inner cab 'a10' not found, msvcp140.dll pre-extraction skipped")
|
||||
|
||||
self.logger.info("vcrun2022: running x86 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x86), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
)
|
||||
# 3010 = reboot required - normal for VC redist, treat as success
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
# x64: same msvcp140.dll pre-extraction workaround
|
||||
win64_tmp = tmpdir_path / 'win64'
|
||||
win64_tmp.mkdir()
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(win64_tmp), '-F', 'a12', str(x64)],
|
||||
capture_output=True,
|
||||
)
|
||||
inner_x64 = win64_tmp / 'a12'
|
||||
if inner_x64.is_file():
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(system32), '-F', 'msvcp140.dll', str(inner_x64)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.logger.warning("vcrun2022: x64 inner cab 'a12' not found, msvcp140.dll pre-extraction skipped")
|
||||
|
||||
self.logger.info("vcrun2022: running x64 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x64), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
critical = [
|
||||
(syswow64 / 'msvcp140.dll', 'msvcp140.dll (x86)'),
|
||||
(syswow64 / 'vcruntime140.dll', 'vcruntime140.dll (x86)'),
|
||||
(system32 / 'msvcp140.dll', 'msvcp140.dll (x64)'),
|
||||
(system32 / 'vcruntime140.dll', 'vcruntime140.dll (x64)'),
|
||||
(system32 / 'vcruntime140_1.dll', 'vcruntime140_1.dll (x64)'),
|
||||
]
|
||||
for path, label in critical:
|
||||
if not path.is_file():
|
||||
self.logger.error("vcrun2022: %s missing after install", label)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _install_vcrun2012(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2012")
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun2012'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vcredist_x86.exe'
|
||||
@@ -402,24 +462,38 @@ class NativeComponentInstaller:
|
||||
return False
|
||||
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
||||
return False
|
||||
|
||||
self._apply_dll_overrides()
|
||||
|
||||
env = self._wine_env_base()
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, dest in [(x86, syswow64), (x64, system32)]:
|
||||
for cab_name in ('a2', 'a3'):
|
||||
td = Path(tmpdir) / (exe.stem + cab_name)
|
||||
td.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True)
|
||||
inner = td / cab_name
|
||||
if not inner.is_file():
|
||||
continue
|
||||
dd = td / 'x'
|
||||
dd.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True)
|
||||
for src in dd.iterdir():
|
||||
if src.name.startswith('f_central_'):
|
||||
shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll'))
|
||||
|
||||
self.logger.info("vcrun2012: running x86 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x86), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
self.logger.info("vcrun2012: running x64 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x64), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
if not (syswow64 / 'msvcr110.dll').is_file():
|
||||
self.logger.error("vcrun2012: msvcr110.dll not extracted to syswow64")
|
||||
self.logger.error("vcrun2012: msvcr110.dll missing after install")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ Handles encrypted storage and retrieval of OAuth tokens
|
||||
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from jackify.backend.utils.machine_crypto import encrypt as _mc_encrypt, decrypt as _mc_decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,140 +40,13 @@ class OAuthTokenHandler:
|
||||
# Ensure config directory exists
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate encryption key based on machine-specific data
|
||||
self._encryption_key = self._generate_encryption_key()
|
||||
|
||||
def _generate_encryption_key(self) -> bytes:
|
||||
"""
|
||||
Generate encryption key based on machine-specific data using Fernet
|
||||
|
||||
Uses hostname + username + machine ID as key material, similar to DPAPI approach.
|
||||
This provides proper symmetric encryption while remaining machine-specific.
|
||||
|
||||
Returns:
|
||||
Fernet-compatible 32-byte encryption key
|
||||
"""
|
||||
import socket
|
||||
import getpass
|
||||
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
|
||||
# Try to get machine ID for additional entropy
|
||||
machine_id = None
|
||||
try:
|
||||
# Linux machine-id
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Alternative locations
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
# Combine multiple sources of machine-specific data
|
||||
if machine_id:
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify"
|
||||
else:
|
||||
key_material = f"{hostname}:{username}:jackify"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get machine info for encryption: {e}")
|
||||
key_material = "jackify:default:key"
|
||||
|
||||
# Generate 32-byte key using SHA256 for Fernet
|
||||
# Fernet requires base64-encoded 32-byte key
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_data(self, data: str) -> str:
|
||||
"""
|
||||
Encrypt data using AES-GCM (authenticated encryption)
|
||||
|
||||
Uses pycryptodome for cross-platform compatibility.
|
||||
AES-GCM provides authenticated encryption similar to Fernet.
|
||||
|
||||
Args:
|
||||
data: Plain text data
|
||||
|
||||
Returns:
|
||||
Encrypted data as base64 string (nonce:ciphertext:tag format)
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
# Derive 32-byte AES key from encryption_key (which is base64-encoded)
|
||||
key = base64.urlsafe_b64decode(self._encryption_key)
|
||||
|
||||
# Generate random nonce (12 bytes for GCM)
|
||||
nonce = get_random_bytes(12)
|
||||
|
||||
# Create AES-GCM cipher
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
|
||||
# Encrypt and get authentication tag
|
||||
data_bytes = data.encode('utf-8')
|
||||
ciphertext, tag = cipher.encrypt_and_digest(data_bytes)
|
||||
|
||||
# Combine nonce:ciphertext:tag and base64 encode
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger.error("pycryptodome package not available for token encryption")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt data: {e}")
|
||||
return ""
|
||||
"""Encrypt data using AES-GCM via machine_crypto."""
|
||||
return _mc_encrypt(data)
|
||||
|
||||
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
|
||||
"""
|
||||
Decrypt data using AES-GCM (authenticated encryption)
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted data string (base64-encoded nonce:ciphertext:tag)
|
||||
|
||||
Returns:
|
||||
Decrypted plain text or None on failure
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't)
|
||||
if not hasattr(AES, 'MODE_GCM'):
|
||||
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
|
||||
return None
|
||||
|
||||
# Derive 32-byte AES key from encryption_key
|
||||
key = base64.urlsafe_b64decode(self._encryption_key)
|
||||
|
||||
# Decode base64 and split nonce:ciphertext:tag
|
||||
combined = base64.b64decode(encrypted_data.encode('utf-8'))
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
ciphertext = combined[12:-16]
|
||||
|
||||
# Create AES-GCM cipher
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
|
||||
# Decrypt and verify authentication tag
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
|
||||
return plaintext.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger.error("pycryptodome package not available for token decryption")
|
||||
return None
|
||||
except AttributeError:
|
||||
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt data: {e}")
|
||||
return None
|
||||
"""Decrypt data. Returns None on any failure."""
|
||||
return _mc_decrypt(encrypted_data)
|
||||
|
||||
def save_token(self, token_data: Dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_ERROR, COLOR_INFO
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_ERROR, COLOR_INFO
|
||||
|
||||
# Initialize logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str:
|
||||
"""Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present.
|
||||
|
||||
Reads existing launch options and appends only what is missing — never overwrites
|
||||
Reads existing launch options and appends only what is missing - never overwrites
|
||||
unrelated options. Adds the top-level directory of each path so Proton's container
|
||||
can bind-mount the subtree into the prefix.
|
||||
|
||||
@@ -47,11 +47,11 @@ class ShortcutLaunchOptionsMixin:
|
||||
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
||||
|
||||
Returns:
|
||||
"unchanged" — mounts already correct, no action needed
|
||||
"updated" — Steam was not running; write succeeded
|
||||
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
||||
"unchanged" - mounts already correct, no action needed
|
||||
"updated" - Steam was not running; write succeeded
|
||||
"steam_running" - changes needed but deferred; call apply_pending_mounts_update()
|
||||
after stopping Steam
|
||||
"failed" — shortcut not found or write error
|
||||
"failed" - shortcut not found or write error
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
@@ -110,7 +110,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
steam_running = False
|
||||
|
||||
if steam_running:
|
||||
# Defer the write — Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Defer the write - Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
||||
self._pending_mounts_app_name = app_name
|
||||
self._pending_mounts_exe_path = exe_path
|
||||
|
||||
@@ -1,293 +1,21 @@
|
||||
"""Steam restart methods for ShortcutHandler (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_steam_exe():
|
||||
"""Resolve steam executable for legacy restart path (same logic as steam_restart_service)."""
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import _get_steam_executable
|
||||
return _get_steam_executable(os.environ)
|
||||
except Exception:
|
||||
import shutil
|
||||
exe = shutil.which("steam")
|
||||
if exe:
|
||||
return exe
|
||||
for p in ("/usr/games/steam", "/usr/bin/steam"):
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
return "steam"
|
||||
|
||||
|
||||
class ShortcutSteamRestartMixin:
|
||||
"""Mixin providing Steam restart methods."""
|
||||
|
||||
def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Secure Steam restart with comprehensive error handling to prevent segfaults.
|
||||
Now delegates to the robust steam restart service for cross-distro compatibility.
|
||||
"""
|
||||
"""Delegate to steam_restart_service (canonical restart path)."""
|
||||
try:
|
||||
from ..services.steam_restart_service import robust_steam_restart
|
||||
return robust_steam_restart(progress_callback=status_callback, timeout=60)
|
||||
except ImportError as e:
|
||||
self.logger.error(f"Failed to import steam restart service: {e}")
|
||||
return self._legacy_secure_steam_restart(status_callback)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in robust steam restart: {e}")
|
||||
return self._legacy_secure_steam_restart(status_callback)
|
||||
|
||||
def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Legacy secure Steam restart implementation (fallback).
|
||||
"""
|
||||
self.logger.info("Attempting secure Steam restart sequence...")
|
||||
|
||||
def safe_subprocess_run(cmd, **kwargs):
|
||||
try:
|
||||
return subprocess.run(cmd, **kwargs)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subprocess error with cmd {cmd}: {e}")
|
||||
return subprocess.CompletedProcess(cmd, 1, "", str(e))
|
||||
|
||||
def safe_subprocess_popen(cmd, **kwargs):
|
||||
try:
|
||||
return subprocess.Popen(cmd, **kwargs)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Popen error with cmd {cmd}: {e}")
|
||||
return None
|
||||
|
||||
if self._is_steam_deck():
|
||||
self.logger.info("Detected Steam Deck. Using systemd to restart Steam.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Restarting Steam via systemd...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
try:
|
||||
result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30)
|
||||
self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}")
|
||||
time.sleep(10)
|
||||
check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
self.logger.info("Steam restarted successfully via systemd.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Steam Started")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Steam did not start after systemd restart.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error restarting Steam via systemd: {e}")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Restart Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Stopping Steam...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...")
|
||||
shutdown_timeout = 30
|
||||
result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True)
|
||||
if result.returncode != 1:
|
||||
self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).")
|
||||
else:
|
||||
self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.")
|
||||
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Waiting for Steam to close...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Verifying Steam processes are terminated...")
|
||||
max_attempts = 6
|
||||
steam_closed_successfully = False
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
check_cmd = ['pgrep', '-f', 'steamwebhelper']
|
||||
self.logger.debug(f"Executing check: {' '.join(check_cmd)}")
|
||||
result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
self.logger.info("No Steam web helper processes found via pgrep.")
|
||||
steam_closed_successfully = True
|
||||
break
|
||||
else:
|
||||
try:
|
||||
steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else []
|
||||
self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error parsing pgrep output: {e}")
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}")
|
||||
time.sleep(5)
|
||||
|
||||
if not steam_closed_successfully:
|
||||
self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Force stopping Steam...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
try:
|
||||
self.logger.info("Attempting force shutdown via 'pkill steam'...")
|
||||
pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True)
|
||||
self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10)
|
||||
if final_check.returncode != 0:
|
||||
self.logger.info("Steam processes successfully terminated via pkill fallback.")
|
||||
steam_closed_successfully = True
|
||||
else:
|
||||
self.logger.debug("Steam processes still running after pkill fallback.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during pkill fallback: {e}")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
if not steam_closed_successfully:
|
||||
self.logger.error("Failed to terminate Steam processes via all methods.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
self.logger.error("steam_restart_service unavailable: %s", e)
|
||||
return False
|
||||
|
||||
self.logger.info("Steam confirmed closed.")
|
||||
|
||||
steam_exe = _resolve_steam_exe()
|
||||
start_methods = [
|
||||
{"name": "Popen", "cmd": [steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}},
|
||||
{"name": "setsid", "cmd": ["setsid", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}},
|
||||
{"name": "nohup", "cmd": ["nohup", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}
|
||||
]
|
||||
steam_start_initiated = False
|
||||
|
||||
for i, method in enumerate(start_methods):
|
||||
method_name = method["name"]
|
||||
status_msg = f"Starting Steam ({method_name})"
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback(status_msg)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info(f"Attempting to start Steam using method: {method_name}")
|
||||
try:
|
||||
process = safe_subprocess_popen(method["cmd"], **method["kwargs"])
|
||||
if process is not None:
|
||||
self.logger.info(f"Initiated Steam start with {method_name}.")
|
||||
time.sleep(5)
|
||||
check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if check_result.returncode == 0:
|
||||
self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.")
|
||||
steam_start_initiated = True
|
||||
break
|
||||
else:
|
||||
self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.")
|
||||
else:
|
||||
self.logger.warning(f"Failed to start process with {method_name}. Trying next method.")
|
||||
except FileNotFoundError:
|
||||
self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.")
|
||||
|
||||
if not steam_start_initiated:
|
||||
self.logger.error("All methods to initiate Steam start failed.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error("robust_steam_restart failed: %s", e)
|
||||
return False
|
||||
|
||||
status_msg = "Waiting for Steam to fully start"
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback(status_msg)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
|
||||
max_startup_wait = 120
|
||||
elapsed_wait = 0
|
||||
initial_wait_done = False
|
||||
|
||||
while elapsed_wait < max_startup_wait:
|
||||
try:
|
||||
result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
if not initial_wait_done:
|
||||
self.logger.info("Steam process detected. Waiting additional time for full initialization...")
|
||||
initial_wait_done = True
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
if initial_wait_done and elapsed_wait >= 15:
|
||||
final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if final_check.returncode == 0:
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Steam Started")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
self.logger.info("Steam confirmed running after wait.")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Steam process disappeared during final initialization wait.")
|
||||
break
|
||||
else:
|
||||
self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error during Steam startup wait: {e}")
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
|
||||
self.logger.error("Steam failed to start/initialize within the allowed time.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Timed Out")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ui_colors import COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_INFO, COLOR_RESET
|
||||
|
||||
def show_status(message: str):
|
||||
"""Show a single-line status message, overwriting the current line."""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UI Color Constants
|
||||
"""
|
||||
|
||||
COLOR_PROMPT = '\033[93m' # Yellow
|
||||
COLOR_SELECTION = '\033[96m' # Cyan
|
||||
COLOR_RESET = '\033[0m'
|
||||
COLOR_INFO = '\033[94m' # Blue
|
||||
COLOR_ERROR = '\033[91m' # Red
|
||||
COLOR_SUCCESS = '\033[92m' # Green
|
||||
COLOR_WARNING = '\033[93m' # Yellow (reusing prompt color)
|
||||
COLOR_DISABLED = '\033[90m' # Grey
|
||||
|
||||
COLOR_ACTION = '\033[97m' # Bright White for action/descriptions
|
||||
COLOR_INPUT = '\033[97m' # Bright White for input prompts
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
UIHandler module for managing user interface operations.
|
||||
This module handles menus, prompts, and user interaction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Tuple, Callable, Any
|
||||
from pathlib import Path
|
||||
|
||||
class UIHandler:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def show_menu(self, title: str, options: List[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Display a menu and get user selection."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
for i, option in enumerate(options, 1):
|
||||
print(f"{i}. {option['label']}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
||||
if choice.lower() == 'q':
|
||||
return None
|
||||
|
||||
choice = int(choice)
|
||||
if 1 <= choice <= len(options):
|
||||
return options[choice - 1]['value']
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
except ValueError:
|
||||
print("Please enter a number.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show menu: {e}")
|
||||
return None
|
||||
|
||||
def show_progress(self, message: str, total: int = 100) -> None:
|
||||
"""Display a progress indicator."""
|
||||
try:
|
||||
print(f"\n{message}")
|
||||
print("[" + " " * 50 + "] 0%", end="\r")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show progress: {e}")
|
||||
|
||||
def update_progress(self, current: int, message: Optional[str] = None) -> None:
|
||||
"""Update the progress indicator."""
|
||||
try:
|
||||
if message:
|
||||
print(f"\n{message}")
|
||||
progress = int(current / 2)
|
||||
print("[" + "=" * progress + " " * (50 - progress) + f"] {current}%", end="\r")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update progress: {e}")
|
||||
|
||||
def show_error(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display an error message."""
|
||||
try:
|
||||
print(f"\nError: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show error: {e}")
|
||||
|
||||
def show_success(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display a success message."""
|
||||
try:
|
||||
print(f"\n✓ Success: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show success: {e}")
|
||||
|
||||
def show_warning(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display a warning message."""
|
||||
try:
|
||||
print(f"\nWarning: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show warning: {e}")
|
||||
|
||||
def get_input(self, prompt: str, default: Optional[str] = None) -> str:
|
||||
"""Get user input with optional default value."""
|
||||
try:
|
||||
if default:
|
||||
user_input = input(f"{prompt} [{default}]: ")
|
||||
return user_input if user_input else default
|
||||
return input(f"{prompt}: ")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get input: {e}")
|
||||
return ""
|
||||
|
||||
def get_confirmation(self, message: str, default: bool = True) -> bool:
|
||||
"""Get user confirmation for an action."""
|
||||
try:
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
while True:
|
||||
response = input(f"{message} [{default_str}]: ").lower()
|
||||
if not response:
|
||||
return default
|
||||
if response in ['y', 'yes']:
|
||||
return True
|
||||
if response in ['n', 'no']:
|
||||
return False
|
||||
print("Please enter 'y' or 'n'.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get confirmation: {e}")
|
||||
return default
|
||||
|
||||
def show_list(self, title: str, items: List[str], selectable: bool = True) -> Optional[str]:
|
||||
"""Display a list of items, optionally selectable."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
print(f"{i}. {item}")
|
||||
|
||||
if selectable:
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
||||
if choice.lower() == 'q':
|
||||
return None
|
||||
|
||||
choice = int(choice)
|
||||
if 1 <= choice <= len(items):
|
||||
return items[choice - 1]
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
except ValueError:
|
||||
print("Please enter a number.")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show list: {e}")
|
||||
return None
|
||||
|
||||
def show_table(self, title: str, headers: List[str], rows: List[List[str]]) -> None:
|
||||
"""Display data in a table format."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
# Calculate column widths
|
||||
widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(str(cell)))
|
||||
|
||||
# Print headers
|
||||
header_str = " | ".join(f"{h:<{w}}" for h, w in zip(headers, widths))
|
||||
print(header_str)
|
||||
print("-" * len(header_str))
|
||||
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(cell):<{w}}" for cell, w in zip(row, widths)))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show table: {e}")
|
||||
|
||||
def show_help(self, topic: str) -> None:
|
||||
"""Display help information for a topic."""
|
||||
try:
|
||||
print(f"\nHelp: {topic}")
|
||||
print("=" * (len(topic) + 6))
|
||||
print("Help content would be displayed here.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show help: {e}")
|
||||
|
||||
def clear_screen(self) -> None:
|
||||
"""Clear the terminal screen."""
|
||||
try:
|
||||
os.system('clear' if os.name == 'posix' else 'cls')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to clear screen: {e}")
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import time
|
||||
import re
|
||||
|
||||
# Import UI Colors first - these should always be available
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_WARNING
|
||||
|
||||
# Import necessary components from other modules
|
||||
try:
|
||||
@@ -444,56 +444,3 @@ class InstallWabbajackHandler(
|
||||
print(f"\nDetailed log available at: {log_path}")
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
|
||||
|
||||
# Example usage (for testing - keep this section for easy module testing)
|
||||
if __name__ == '__main__':
|
||||
# Configure logging for standalone testing
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
print("Testing Wabbajack Install Handler...")
|
||||
# Simulate running on or off deck
|
||||
test_on_deck = False
|
||||
print(f"Simulating run with steamdeck={test_on_deck}")
|
||||
|
||||
# Need dummy handlers for direct testing
|
||||
class DummyProton:
|
||||
which_protontricks = 'native'
|
||||
def check_and_setup_protontricks(self): return True
|
||||
def set_protontricks_permissions(self, path, steamdeck): return True
|
||||
def enable_dotfiles(self, appid): return True
|
||||
def _cleanup_wine_processes(self): pass
|
||||
def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0)
|
||||
def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"}
|
||||
|
||||
class DummyShortcut:
|
||||
def create_shortcut(self, *args, **kwargs): return True, "12345"
|
||||
def secure_steam_restart(self): return True
|
||||
|
||||
class DummyPath:
|
||||
def find_compat_data(self, appid): return Path(f"/tmp/test_compat/{appid}")
|
||||
def find_steam_library(self): return Path("/tmp/test_steam/steamapps/common")
|
||||
|
||||
class DummyVDF:
|
||||
@staticmethod
|
||||
def load(path):
|
||||
if "config.vdf" in str(path):
|
||||
# Simulate structure needed for proton check
|
||||
return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}}
|
||||
return {}
|
||||
|
||||
handler = InstallWabbajackHandler(
|
||||
steamdeck=test_on_deck,
|
||||
protontricks_handler=DummyProton(),
|
||||
shortcut_handler=DummyShortcut(),
|
||||
path_handler=DummyPath(),
|
||||
vdf_handler=DummyVDF(),
|
||||
modlist_handler=ModlistHandler(),
|
||||
filesystem_handler=FileSystemHandler()
|
||||
)
|
||||
# Pre-create dummy compatdata dir for verification step
|
||||
if not Path("/tmp/test_compat/12345/pfx").exists():
|
||||
os.makedirs("/tmp/test_compat/12345/pfx", exist_ok=True)
|
||||
|
||||
handler.run_install_workflow()
|
||||
|
||||
print("\nTesting completed.")
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import os
|
||||
|
||||
from .status_utils import clear_status, show_status
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .status_utils import clear_status, show_status
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -222,7 +222,15 @@ class WineUtilsProtonMixin:
|
||||
Path("/usr/share/steam/compatibilitytools.d"),
|
||||
Path("/usr/lib/steam/compatibilitytools.d"),
|
||||
]
|
||||
return [path for path in compat_paths if path.exists()]
|
||||
seen_real = set()
|
||||
result = []
|
||||
for path in compat_paths:
|
||||
if path.exists():
|
||||
real = path.resolve()
|
||||
if real not in seen_real:
|
||||
seen_real.add(real)
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
|
||||
@@ -441,10 +449,19 @@ class WineUtilsProtonMixin:
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
|
||||
_TYPE_RANK = {'GE-Proton': 2, 'ThirdParty-Proton': 1, 'Valve-Proton': 0}
|
||||
def _type_rank(v: dict) -> int:
|
||||
t = v.get('type', '')
|
||||
if t == 'GE-Proton':
|
||||
return 2 if v.get('major_version', 0) >= 10 else -1
|
||||
if t == 'ThirdParty-Proton':
|
||||
return 1
|
||||
if t == 'Valve-Proton':
|
||||
return 0
|
||||
return -1
|
||||
|
||||
all_versions.sort(
|
||||
key=lambda x: (
|
||||
_TYPE_RANK.get(x.get('type', ''), 0),
|
||||
_type_rank(x),
|
||||
x.get('major_version', 0),
|
||||
x.get('minor_version', 0),
|
||||
x.get('priority', 0),
|
||||
|
||||
Reference in New Issue
Block a user