Files
Vual/src/vual/ui/pref_ce.py
T

461 lines
16 KiB
Python

"""Cheat Engine preferences page — status, download, extract, protonhax, locale."""
from __future__ import annotations
import os
import subprocess
import threading
from pathlib import Path
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
from vual import cheatengine, protonhax # noqa: E402
from vual.i18n import _ # noqa: E402
if TYPE_CHECKING:
from vual.ui.preferences import PreferencesWindow
def build_ce_page(win: PreferencesWindow) -> Adw.PreferencesPage:
"""Build the Cheat Engine preferences page."""
cfg = win._config
page = Adw.PreferencesPage(
title="Cheat Engine",
icon_name="applications-games-symbolic",
)
# ── Status group ─────────────────────────────────────────────
status_group = Adw.PreferencesGroup(title=_("Status"))
page.add(status_group)
ce_status_row = Adw.ActionRow(title="Cheat Engine", subtitle=_("Checking..."))
ce_status_row.set_activatable(False)
status_group.add(ce_status_row)
ph_status_row = Adw.ActionRow(title="protonhax", subtitle=_("Checking..."))
ph_status_row.set_activatable(False)
status_group.add(ph_status_row)
# ── Installation group ───────────────────────────────────────
install_group = Adw.PreferencesGroup(
title=_("Installation"),
description=_("Cheat Engine setup and download"),
)
page.add(install_group)
ce_path_row = Adw.EntryRow(title=_("Executable"))
ce_path_row.set_text(cfg.ce_executable)
ce_path_row.connect("changed", lambda row: _on_ce_path_changed(win, row, state))
browse_btn = Gtk.Button(
icon_name="document-open-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat"],
tooltip_text=_("Choose file"),
)
browse_btn.connect("clicked", lambda _: _on_browse_ce(win, ce_path_row, state))
ce_path_row.add_suffix(browse_btn)
install_group.add(ce_path_row)
dl_row = Adw.ActionRow(title=_("Latest version"), subtitle=_("Click Check"))
check_btn = Gtk.Button(label=_("Check"), valign=Gtk.Align.CENTER)
check_btn.connect("clicked", lambda _: _on_check_release(win, dl_row, check_btn, state))
dl_row.add_suffix(check_btn)
install_group.add(dl_row)
progress_row = Adw.ActionRow(title=_("Download"), subtitle="", visible=False)
progress = Gtk.ProgressBar(valign=Gtk.Align.CENTER, show_text=True)
progress.set_size_request(150, -1)
progress_row.add_suffix(progress)
install_group.add(progress_row)
# ── protonhax group ──────────────────────────────────────────
ph_group = Adw.PreferencesGroup(
title="protonhax",
description=_("Required for attaching to Proton games"),
)
page.add(ph_group)
ph_row = Adw.ActionRow(
title=_("Install or update"),
subtitle=_("Downloads latest version from GitHub"),
)
ph_btn = Gtk.Button(label=_("Install"), valign=Gtk.Align.CENTER)
ph_btn.connect("clicked", lambda _: _on_install_protonhax(win, state))
ph_row.add_suffix(ph_btn)
ph_group.add(ph_row)
# ── Language group ───────────────────────────────────────────
lang_group = Adw.PreferencesGroup(
title=_("Language"),
description=_("Cheat Engine interface language"),
)
page.add(lang_group)
lang_row = Adw.ComboRow(title=_("Interface"), subtitle=_("Requires CE restart"))
lang_model = Gtk.StringList.new([_("System"), _("Russian")])
lang_row.set_model(lang_model)
lang_idx = {"system": 0, "ru_RU": 1}.get(cfg.ce_language, 0)
lang_row.set_selected(lang_idx)
lang_row.connect("notify::selected", lambda row, _: _on_lang_changed(win, row))
lang_group.add(lang_row)
loc_row = Adw.ActionRow(title=_("Russian localization"), subtitle=_("Checking..."))
loc_btn = Gtk.Button(label=_("Install"), valign=Gtk.Align.CENTER)
loc_btn.connect("clicked", lambda _: _on_install_localization(win, loc_row, loc_btn))
loc_row.add_suffix(loc_btn)
lang_group.add(loc_row)
# ── Debug group ──────────────────────────────────────────────
debug_group = Adw.PreferencesGroup(title=_("Debug"))
page.add(debug_group)
test_row = Adw.ActionRow(
title=_("Test launch"),
subtitle=_("Launch CE via Wine (without game binding)"),
)
test_btn = Gtk.Button(
label=_("Launch"),
valign=Gtk.Align.CENTER,
css_classes=["suggested-action"],
)
test_btn.connect("clicked", lambda _: _on_test_launch(win))
test_row.add_suffix(test_btn)
debug_group.add(test_row)
# Shared mutable state for cross-function access
state = _PageState(
ce_status_row=ce_status_row,
ph_status_row=ph_status_row,
ce_path_row=ce_path_row,
dl_row=dl_row,
check_btn=check_btn,
progress_row=progress_row,
progress=progress,
ph_btn=ph_btn,
loc_row=loc_row,
loc_btn=loc_btn,
test_btn=test_btn,
)
# Initial status refresh
_refresh_status(win, state)
return page
class _PageState:
"""Holds widget references for the CE page."""
def __init__(self, **kwargs):
self.release_info: dict | None = None
self.download_btn: Gtk.Button | None = None
for k, v in kwargs.items():
setattr(self, k, v)
# ── Status ───────────────────────────────────────────────────────
def _refresh_status(win: PreferencesWindow, state: _PageState) -> None:
cfg = win._config
# CE status
if cfg.ce_exists:
version = cheatengine.detect_version(cfg.ce_executable_path)
ver_str = f" ({version})" if version else ""
state.ce_status_row.set_subtitle(_("✓ Installed") + ver_str)
state.test_btn.set_sensitive(True)
else:
state.ce_status_row.set_subtitle(_("✗ Not found"))
state.test_btn.set_sensitive(False)
# protonhax status
ph_path = protonhax.find_installed()
if ph_path:
if protonhax.is_managed():
if protonhax.needs_update():
state.ph_status_row.set_subtitle(_("⟳ Update available"))
state.ph_btn.set_label(_("Refresh"))
else:
state.ph_status_row.set_subtitle(_("✓ Installed"))
state.ph_btn.set_label(_("Reinstall"))
else:
state.ph_status_row.set_subtitle(_("✓ External: %s") % ph_path)
else:
state.ph_status_row.set_subtitle(_("✗ Not installed"))
state.ph_btn.set_label(_("Install"))
# Localization status
if cfg.ce_exists:
if cheatengine.is_localization_installed(cfg.ce_executable_path):
state.loc_row.set_subtitle(_("✓ Installed"))
state.loc_btn.set_label(_("Reinstall"))
else:
state.loc_row.set_subtitle(_("Not installed"))
state.loc_btn.set_label(_("Install"))
state.loc_btn.set_sensitive(True)
else:
state.loc_row.set_subtitle(_("Cheat Engine required"))
state.loc_btn.set_sensitive(False)
# ── Callbacks ────────────────────────────────────────────────────
def _on_ce_path_changed(win: PreferencesWindow, row: Adw.EntryRow, state: _PageState) -> None:
win._config.ce_executable = row.get_text().strip()
win._save()
_refresh_status(win, state)
def _on_browse_ce(win: PreferencesWindow, ce_path_row: Adw.EntryRow, state: _PageState) -> None:
dialog = Gtk.FileDialog(title=_("Select CE executable"))
filters = Gio.ListStore.new(Gtk.FileFilter)
exe_filter = Gtk.FileFilter()
exe_filter.set_name(_("Executable files (*.exe)"))
exe_filter.add_pattern("*.exe")
filters.append(exe_filter)
all_filter = Gtk.FileFilter()
all_filter.set_name(_("All files"))
all_filter.add_pattern("*")
filters.append(all_filter)
dialog.set_filters(filters)
def on_result(dlg: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
gfile = dlg.open_finish(result)
if gfile:
path = gfile.get_path()
home = str(Path.home())
display = path.replace(home, "~", 1) if path.startswith(home) else path
ce_path_row.set_text(display)
except GLib.Error:
pass
dialog.open(win, None, on_result)
def _on_lang_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
langs = ["system", "ru_RU"]
win._config.ce_language = langs[row.get_selected()]
win._save()
def _on_install_protonhax(win: PreferencesWindow, state: _PageState) -> None:
try:
protonhax.ensure_installed()
_refresh_status(win, state)
win._show_toast(_("protonhax installed"))
except OSError as e:
win._show_toast(_("Error: %s") % e)
def _on_install_localization(
win: PreferencesWindow, loc_row: Adw.ActionRow, loc_btn: Gtk.Button,
) -> None:
cfg = win._config
if not cfg.ce_exists:
win._show_toast(_("Cheat Engine not found"))
return
loc_btn.set_sensitive(False)
loc_row.set_subtitle(_("Downloading..."))
def worker() -> None:
ok = cheatengine.install_localization(cfg.ce_executable_path)
GLib.idle_add(_on_localization_done, win, ok, loc_btn, loc_row)
threading.Thread(target=worker, daemon=True).start()
def _on_localization_done(
win: PreferencesWindow, success: bool, btn: Gtk.Button, loc_row: Adw.ActionRow,
) -> None:
btn.set_sensitive(True)
if success:
loc_row.set_subtitle(_("✓ Installed"))
btn.set_label(_("Reinstall"))
win._show_toast(_("Localization installed"))
else:
loc_row.set_subtitle(_("Not installed"))
win._show_toast(_("Failed to install localization"))
def _on_test_launch(win: PreferencesWindow) -> None:
cfg = win._config
if not cfg.ce_exists:
win._show_toast(_("Cheat Engine not found"))
return
wine = cheatengine.find_proton_wine(cfg.steam_path)
if not wine:
win._show_toast(_("Wine not found — install Proton"))
return
env = os.environ.copy()
prefix = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
prefix.mkdir(parents=True, exist_ok=True)
env["WINEPREFIX"] = str(prefix)
cmd = [str(wine), str(cfg.ce_executable_path)]
if cfg.ce_language != "system":
cmd.extend(["--LANG", cfg.ce_language])
try:
subprocess.Popen(
cmd,
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
win._show_toast(_("Cheat Engine launched"))
except OSError as e:
win._show_toast(_("Error: %s") % e)
# ── Download & Extract ───────────────────────────────────────────
def _on_check_release(
win: PreferencesWindow, dl_row: Adw.ActionRow,
check_btn: Gtk.Button, state: _PageState,
) -> None:
dl_row.set_subtitle(_("Checking..."))
check_btn.set_sensitive(False)
def worker() -> None:
info = cheatengine.get_latest_release()
GLib.idle_add(_on_release_checked, win, info, dl_row, check_btn, state)
threading.Thread(target=worker, daemon=True).start()
def _on_release_checked(
win: PreferencesWindow, info: dict | None,
dl_row: Adw.ActionRow, check_btn: Gtk.Button, state: _PageState,
) -> None:
check_btn.set_sensitive(True)
if not info or not info.get("url"):
dl_row.set_subtitle(_("Failed to get info"))
return
state.release_info = info
version = info.get("version", "?")
size_mb = info.get("size", 0) / 1_048_576
subtitle = f"v{version}"
if size_mb > 0:
subtitle += f" ({size_mb:.1f} " + _("MB") + ")"
# Check if installed version matches latest
cfg = win._config
installed = cheatengine.detect_version(cfg.ce_executable_path) if cfg.ce_exists else None
already_installed = installed is not None and installed == version
if already_installed:
subtitle += " — " + _("already installed")
dl_row.set_subtitle(subtitle)
# Replace check button with download button
dl_row.remove(check_btn)
dl_btn = Gtk.Button(
label=_("Download"),
valign=Gtk.Align.CENTER,
css_classes=["suggested-action"],
sensitive=not already_installed,
)
dl_btn.connect("clicked", lambda _: _on_download_ce(win, state))
dl_row.add_suffix(dl_btn)
state.download_btn = dl_btn
def _on_download_ce(win: PreferencesWindow, state: _PageState) -> None:
if not state.release_info:
return
url = state.release_info["url"]
name = state.release_info.get("name", "CheatEngine.exe")
cache_dir = Path.home() / ".cache" / "vual"
cache_dir.mkdir(parents=True, exist_ok=True)
installer = cache_dir / name
dest_dir = Path.home() / ".local" / "share" / "vual" / "cheatengine"
state.progress_row.set_visible(True)
state.progress_row.set_subtitle(_("Downloading: %s") % name)
state.progress.set_fraction(0)
if state.download_btn:
state.download_btn.set_sensitive(False)
def update_progress(frac: float) -> None:
GLib.idle_add(state.progress.set_fraction, frac)
def worker() -> None:
ok = cheatengine.download_file(url, installer, progress_cb=update_progress)
if not ok:
GLib.idle_add(_download_failed, state)
return
GLib.idle_add(_start_extraction, win, installer, dest_dir, state)
threading.Thread(target=worker, daemon=True).start()
def _download_failed(state: _PageState) -> None:
state.progress_row.set_visible(False)
if state.download_btn:
state.download_btn.set_sensitive(True)
def _start_extraction(
win: PreferencesWindow, installer: Path, dest_dir: Path, state: _PageState,
) -> None:
state.progress_row.set_subtitle(_("Extracting..."))
state.progress.set_fraction(0.5)
def worker() -> None:
extracted = cheatengine.extract_installer(
installer, dest_dir, win._config.steam_path,
)
exe = cheatengine.find_executable(dest_dir) if extracted else None
GLib.idle_add(_extraction_done, win, exe, extracted, installer, state)
threading.Thread(target=worker, daemon=True).start()
def _extraction_done(
win: PreferencesWindow,
exe_path: Path | None,
extracted: bool,
installer: Path,
state: _PageState,
) -> None:
state.progress_row.set_visible(False)
if state.download_btn:
state.download_btn.set_sensitive(True)
if exe_path:
home = str(Path.home())
display = str(exe_path).replace(home, "~", 1)
win._config.ce_executable = display
state.ce_path_row.set_text(display)
win._save()
_refresh_status(win, state)
installer.unlink(missing_ok=True)
win._show_toast(_("Cheat Engine installed"))
elif extracted:
_refresh_status(win, state)
win._show_toast(_("Extracted — specify path manually"))
else:
win._show_toast(_("Extraction failed"))