mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 00:23:42 +02:00
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""
|
|
Diagnostic bundle service - collects logs, system info, and prefix records
|
|
into a tar.gz for support reporting.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def build_bundle(output_dir: Optional[Path] = None) -> Path:
|
|
"""
|
|
Collect logs, system info, and per-prefix component records into a tar.gz.
|
|
Returns the path to the created bundle file.
|
|
"""
|
|
from jackify.shared.paths import get_jackify_logs_dir, get_jackify_data_dir
|
|
from jackify import __version__
|
|
|
|
if output_dir is None:
|
|
output_dir = get_jackify_data_dir() / "DiagnosticBundles"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
bundle_name = f"jackify_diagnostic_{timestamp}.tar.gz"
|
|
bundle_path = output_dir / bundle_name
|
|
|
|
with tempfile.TemporaryDirectory() as staging_dir:
|
|
staging = Path(staging_dir)
|
|
|
|
# System info
|
|
_write_text(staging / "system_info.txt", _collect_system_info(__version__))
|
|
|
|
# Logs
|
|
logs_dir = get_jackify_logs_dir()
|
|
log_staging = staging / "logs"
|
|
log_staging.mkdir()
|
|
cutoff = datetime.now().timestamp() - timedelta(days=7).total_seconds()
|
|
if logs_dir.is_dir():
|
|
for log_file in sorted(logs_dir.glob("*.log*")):
|
|
if log_file.is_file() and log_file.stat().st_mtime >= cutoff:
|
|
try:
|
|
shutil.copy2(log_file, log_staging / log_file.name)
|
|
except Exception as exc:
|
|
logger.debug("Could not copy log %s: %s", log_file.name, exc)
|
|
|
|
# Config files (credentials scrubbed)
|
|
_collect_config_files(staging)
|
|
|
|
# Per-prefix component records
|
|
_collect_component_records(staging)
|
|
|
|
# Modlist shortcut info
|
|
_collect_modlist_info(staging)
|
|
|
|
with tarfile.open(bundle_path, "w:gz") as tar:
|
|
tar.add(staging_dir, arcname="jackify_diagnostic")
|
|
|
|
logger.info("Diagnostic bundle written: %s", bundle_path)
|
|
return bundle_path
|
|
|
|
|
|
def _collect_system_info(version: str) -> str:
|
|
lines = [
|
|
f"Jackify version: {version}",
|
|
f"Date: {datetime.now().isoformat()}",
|
|
f"Kernel: {platform.release()}",
|
|
f"Machine: {platform.machine()}",
|
|
"",
|
|
]
|
|
|
|
_append_engine_info(lines)
|
|
|
|
# Distro
|
|
try:
|
|
import distro
|
|
lines.append(f"Distro: {distro.name(pretty=True)}")
|
|
except ImportError:
|
|
try:
|
|
lines.append(f"Distro: {platform.freedesktop_os_release().get('PRETTY_NAME', 'unknown')}")
|
|
except Exception:
|
|
lines.append("Distro: unknown")
|
|
|
|
# glibc
|
|
try:
|
|
glibc = platform.libc_ver()
|
|
lines.append(f"glibc: {glibc[0]} {glibc[1]}")
|
|
except Exception:
|
|
lines.append("glibc: unknown")
|
|
|
|
# GPU
|
|
try:
|
|
gpu_out = subprocess.check_output(
|
|
["lspci", "-mm"],
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
text=True,
|
|
)
|
|
gpu_lines = [l for l in gpu_out.splitlines() if "VGA" in l or "3D" in l or "Display" in l]
|
|
for gl in gpu_lines[:2]:
|
|
lines.append(f"GPU: {gl.strip()}")
|
|
except Exception:
|
|
lines.append("GPU: unavailable (lspci not found)")
|
|
|
|
lines.append("")
|
|
|
|
# Steam type
|
|
_append_steam_info(lines)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _append_steam_info(lines: list) -> None:
|
|
flatpak_steam = Path.home() / ".var/app/com.valvesoftware.Steam"
|
|
native_steam = Path.home() / ".local/share/Steam"
|
|
|
|
if flatpak_steam.is_dir():
|
|
lines.append("Steam: Flatpak")
|
|
elif native_steam.is_dir():
|
|
lines.append("Steam: Native")
|
|
else:
|
|
lines.append("Steam: not detected")
|
|
|
|
# Proton versions — official builds in steamapps/common, community builds in compatibilitytools.d
|
|
proton_scan = [
|
|
(native_steam / "steamapps/common", "valve"),
|
|
(flatpak_steam / "data/Steam/steamapps/common", "valve"),
|
|
(native_steam / "compatibilitytools.d", "community"),
|
|
(flatpak_steam / "data/Steam/compatibilitytools.d", "community"),
|
|
(Path.home() / ".steam/root/compatibilitytools.d", "community"),
|
|
]
|
|
proton_versions = []
|
|
seen = set()
|
|
for root, source in proton_scan:
|
|
if not root.is_dir():
|
|
continue
|
|
for entry in sorted(root.iterdir()):
|
|
if entry.is_dir() and entry.name not in seen and "proton" in entry.name.lower():
|
|
seen.add(entry.name)
|
|
proton_versions.append((entry.name, source))
|
|
|
|
if proton_versions:
|
|
lines.append("Proton versions:")
|
|
for name, source in sorted(proton_versions):
|
|
lines.append(f" {name} ({source})")
|
|
else:
|
|
lines.append("Proton versions: none found")
|
|
|
|
|
|
def _append_engine_info(lines: list) -> None:
|
|
try:
|
|
from jackify.backend.services.tool_registry import get_active_engine_id, ENGINE_TOOL_IDS, _read_manifest
|
|
active = get_active_engine_id()
|
|
lines.append(f"Active engine: {active}")
|
|
for tool_id in ENGINE_TOOL_IDS:
|
|
try:
|
|
manifest = _read_manifest(tool_id)
|
|
installed_version = manifest.get("installed_version")
|
|
if installed_version:
|
|
lines.append(f" {tool_id}: {installed_version}")
|
|
else:
|
|
lines.append(f" {tool_id}: not installed")
|
|
except Exception:
|
|
lines.append(f" {tool_id}: unknown")
|
|
except Exception as e:
|
|
lines.append(f"Engine info: unavailable ({e})")
|
|
lines.append("")
|
|
|
|
|
|
_CREDENTIAL_KEYS = {"nexus_api_key", "api_key", "access_token", "refresh_token", "token"}
|
|
_EXCLUDED_CONFIG_FILES = {"nexus-oauth.json"}
|
|
|
|
|
|
def _collect_config_files(staging: Path) -> None:
|
|
"""Copy ~/.config/jackify files, excluding credential files and scrubbing credential fields."""
|
|
config_dir = Path.home() / ".config" / "jackify"
|
|
if not config_dir.is_dir():
|
|
return
|
|
|
|
cfg_staging = staging / "config"
|
|
cfg_staging.mkdir()
|
|
|
|
for cfg_file in sorted(config_dir.iterdir()):
|
|
if not cfg_file.is_file():
|
|
continue
|
|
if cfg_file.name in _EXCLUDED_CONFIG_FILES:
|
|
continue
|
|
if cfg_file.suffix == ".json":
|
|
try:
|
|
data = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
_scrub_credentials(data)
|
|
(cfg_staging / cfg_file.name).write_text(
|
|
json.dumps(data, indent=2), encoding="utf-8"
|
|
)
|
|
continue
|
|
except Exception as exc:
|
|
logger.debug("Could not parse %s for scrubbing: %s", cfg_file.name, exc)
|
|
try:
|
|
shutil.copy2(cfg_file, cfg_staging / cfg_file.name)
|
|
except Exception as exc:
|
|
logger.debug("Could not copy config file %s: %s", cfg_file.name, exc)
|
|
|
|
|
|
def _scrub_credentials(obj: object) -> None:
|
|
"""Recursively replace credential field values with '[REDACTED]' in-place."""
|
|
if isinstance(obj, dict):
|
|
for key in list(obj.keys()):
|
|
if key.startswith("nexus_premium_cache_"):
|
|
del obj[key]
|
|
elif any(cred in key.lower() for cred in _CREDENTIAL_KEYS):
|
|
if obj[key] is not None:
|
|
obj[key] = "[REDACTED]"
|
|
else:
|
|
_scrub_credentials(obj[key])
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
_scrub_credentials(item)
|
|
|
|
|
|
def _collect_component_records(staging: Path) -> None:
|
|
"""Find jackify_components.json files in known prefix locations and copy them."""
|
|
steam_compat = Path.home() / ".steam/root/steamapps/compatdata"
|
|
flatpak_compat = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata"
|
|
|
|
cutoff = datetime.now().timestamp() - timedelta(days=30).total_seconds()
|
|
found = []
|
|
for base in (steam_compat, flatpak_compat):
|
|
if not base.is_dir():
|
|
continue
|
|
try:
|
|
for pfx_dir in base.iterdir():
|
|
record = pfx_dir / "pfx" / "jackify_components.json"
|
|
if record.is_file() and record.stat().st_mtime >= cutoff:
|
|
found.append((pfx_dir.name, record))
|
|
except PermissionError:
|
|
pass
|
|
|
|
if not found:
|
|
return
|
|
|
|
comp_staging = staging / "component_records"
|
|
comp_staging.mkdir()
|
|
for appid, record_path in found:
|
|
dest = comp_staging / f"jackify_components_{appid}.json"
|
|
try:
|
|
shutil.copy2(record_path, dest)
|
|
except Exception as exc:
|
|
logger.debug("Could not copy component record for %s: %s", appid, exc)
|
|
|
|
|
|
def _collect_modlist_info(staging: Path) -> None:
|
|
"""Collect installed modlist details from Steam shortcuts.vdf and config.vdf."""
|
|
try:
|
|
from jackify.backend.services.install_verifier_service import _load_verifier
|
|
vmod = _load_verifier()
|
|
modlists = vmod.discover_installed_modlists()
|
|
except Exception as exc:
|
|
logger.debug("Could not discover modlists for bundle: %s", exc)
|
|
return
|
|
|
|
if not modlists:
|
|
return
|
|
|
|
# Build a lookup of launch options keyed by unsigned appid from shortcuts.vdf
|
|
launch_opts: dict = {}
|
|
try:
|
|
for vdf_path in vmod._find_shortcuts_vdf_paths():
|
|
for sc in vmod._parse_shortcuts_vdf(vdf_path):
|
|
raw = sc.get("appid", sc.get("AppID", sc.get("appId")))
|
|
if raw is None:
|
|
continue
|
|
try:
|
|
unsigned = str(vmod._signed_to_unsigned(int(raw)))
|
|
except Exception:
|
|
continue
|
|
lo = sc.get("LaunchOptions", sc.get("launchoptions", ""))
|
|
if lo:
|
|
launch_opts[unsigned] = lo
|
|
except Exception as exc:
|
|
logger.debug("Could not read launch options from shortcuts.vdf: %s", exc)
|
|
|
|
# Read config.vdf once for Proton mappings
|
|
proton_map: dict = {}
|
|
try:
|
|
for root in vmod._find_steam_roots():
|
|
cfg = root / "config" / "config.vdf"
|
|
if cfg.is_file():
|
|
content = cfg.read_text(encoding="utf-8", errors="replace")
|
|
for m in modlists:
|
|
appid = m.get("appid", "")
|
|
if appid and appid not in proton_map:
|
|
tool = vmod._vdf_extract_compat_tool(content, appid)
|
|
if tool:
|
|
proton_map[appid] = tool
|
|
break
|
|
except Exception as exc:
|
|
logger.debug("Could not read Proton versions from config.vdf: %s", exc)
|
|
|
|
records = []
|
|
for m in modlists:
|
|
appid = m.get("appid", "")
|
|
records.append({
|
|
"name": m.get("name", "Unknown"),
|
|
"appid": appid,
|
|
"install_dir": str(m.get("modlist_dir", "")),
|
|
"game_type": m.get("game_type", "unknown"),
|
|
"proton_version": proton_map.get(appid),
|
|
"launch_options": launch_opts.get(appid),
|
|
})
|
|
|
|
_write_text(staging / "modlists.json", json.dumps(records, indent=2))
|
|
|
|
|
|
def _write_text(path: Path, content: str) -> None:
|
|
try:
|
|
path.write_text(content, encoding="utf-8")
|
|
except Exception as exc:
|
|
logger.debug("Could not write %s: %s", path, exc)
|