Release v0.7.1 - Remote Manifest System, Stability Fixes

This commit is contained in:
Omni
2026-07-02 21:04:19 +01:00
parent 6def3b480a
commit e5d329a2dc
96 changed files with 1950 additions and 1382 deletions
+37 -11
View File
@@ -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."""
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+6 -133
View File
@@ -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 -1
View File
@@ -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."""
-16
View File
@@ -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
-179
View File
@@ -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__)
+1 -54
View File
@@ -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__)
+20 -3
View File
@@ -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),