From 2e5860650fbc9fff24e302c1f2d542d62443cfe5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 00:52:48 +0900 Subject: [PATCH] feat: catch cancel and shutdown events --- dzgui/controllers/mc.py | 12 +++++++- dzgui/init/proc.py | 10 +++---- dzgui/managers/connection.py | 17 +++++++---- dzgui/managers/threading.py | 20 ++++++++++--- dzgui/model/model_factory.py | 3 +- dzgui/views/components/statusbar.py | 4 ++- dzgui/views/dialogs/generic.py | 21 +++++++++++++- dzgui/views/dialogs/servers.py | 2 +- dzgui/views/pages/preconnect.py | 45 +++++++++++++++-------------- 9 files changed, 94 insertions(+), 40 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 093da58..eebabf9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_log import LogTreeView @@ -90,6 +91,7 @@ class Controller(GObject.GObject): self.pending_jobs = 1 self.exit_event = threading.Event() + self.cancel_event = threading.Event() self.connection_man: ConnectionManager def get_emitter(self) -> Emitter: @@ -463,6 +465,15 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() + def set_cancel_event(self) -> None: + self.cancel_event.set() + + def get_cancel_event(self) -> threading.Event: + return self.cancel_event + + def clear_cancel_event(self) -> None: + self.cancel_event.clear() + def open_connection_assistant(self, prereqs: "Prerequisites") -> None: self.open_page(NotebookPage.CONNECTION) self.mediator.preconnect.populate(prereqs) @@ -479,4 +490,3 @@ class Controller(GObject.GObject): def get_steam_client_name(self) -> str: return self.mediator.options.get_client_name() - diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 36eda25..b16b994 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -1,27 +1,26 @@ import psutil import subprocess import shutil -import sys from dzgui.const.constants import ( DAYZ_BINARY, STEAM_CMD, FLATPAK_APPID, + FLATPAK_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, ) -from dzgui.views.dialogs.early_alert import EarlyAlertDialog -from dzgui.util.strings import init # TODO: move to util.proc def is_dayz_running() -> bool: + """Subprocesses spawned from Steam will not show up in regular process tree""" procs = [] substring = DAYZ_BINARY for proc in psutil.process_iter(): try: procs.append(proc.cmdline()) - except Exception as e: + except Exception: continue return any(substring in item for sublist in procs for item in sublist) @@ -37,10 +36,11 @@ def is_steam_running(cmd: str) -> bool: raise TypeError("Not a valid Steam client selection") +# CHORE: test alternate clients def is_flatpak_steam_running() -> bool: if has_cmd(FLATPAK_CMD) is False: return False - proc = subprocess.check_output([FLATPAK_CMD, "ps"], capture_output=True, text=True) + proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True) lines = proc.stdout.splitlines() if FLATPAK_APPID in lines: return True diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 928de00..5675e67 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -75,7 +75,6 @@ class Prerequisites: dayz_running: bool steam_proc: SteamProcess mods: list[str] - missing_mods: int foreground_cmd: str | None game_mode: bool @@ -184,7 +183,6 @@ class ConnectionManager: dayz_running=dayz_running, steam_proc=steam_proc, mods=remote_mods, - missing_mods=len(self.missing_mods), foreground_cmd=self.foreground_cmd, # TODO: change to bool game_mode=game_mode, ) @@ -245,7 +243,6 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - # TODO: threading def _connect_steam(self) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) @@ -256,8 +253,12 @@ class ConnectionManager: self.thread_man.set_cleanup_func(func) return + self.thread_man.show_cancel(False) self.thread_man.update_dialog(waiting_for_launch) while True: + if self.controller.get_exit_event().is_set(): + # TODO: some facility to also close spawned steam process + return if is_dayz_running(): break time.sleep(1) @@ -290,6 +291,12 @@ class ConnectionManager: while True: # NOTE: mods will finish at the same time # TODO: check for early cancel event + # TODO: check global sigint event + if self.controller.get_exit_event().is_set(): + return + if self.controller.get_cancel_event().is_set(): + self.controller.clear_cancel_event() + return cur_size = get_mod_dir_size(mod_path) if cur_size == size: break @@ -298,11 +305,11 @@ class ConnectionManager: update_signatures(self.missing_mods, prefs.paths.version) # TODO: get config path or just push steam path directly rebuild_symlinks(prefs.paths.config) + self._connect_steam() - @call_on_thread("Waiting for Steam to update mods") + @call_on_thread("Waiting for Steam to update mods", show_cancel=True) def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: self._update_mods(raise_window) - self._connect_steam() else: self._connect_steam() diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 52f884a..6c6e20f 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -20,7 +20,9 @@ from gi.repository import Gtk, GLib # noqa E402 logger = logging.getLogger(APP_NAME) -def call_on_thread(dialog_str: str, show_dialog: bool = True) -> Callable: +def call_on_thread( + dialog_str: str, show_dialog: bool = True, show_cancel: bool = False +) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> None: @@ -32,7 +34,7 @@ def call_on_thread(dialog_str: str, show_dialog: bool = True) -> Callable: raise TypeError( "Attribute 'thread_man' must be of type 'ThreadingManager'" ) - self.thread_man.call_on_thread(dialog_str, stored, show_dialog) + self.thread_man.call_on_thread(dialog_str, stored, show_dialog, show_cancel) return wrapper @@ -57,15 +59,22 @@ class ThreadingManager: self.destroy_first = False def call_on_thread( - self, dialog_str: str, func: StoredFunc, show_dialog: bool = True + self, + dialog_str: str, + func: StoredFunc, + show_dialog: bool = True, + show_cancel: bool = False, ) -> None: def callback() -> None: func.call() GLib.idle_add(self._destroy_on_idle) if show_dialog: - self.wait_dialog = WaitDialog(self.controller, dialog_str, jobs=self.jobs) + self.wait_dialog = WaitDialog( + self.controller, dialog_str, jobs=self.jobs, show_cancel=show_cancel + ) self.wait_dialog.show_all() + thread = threading.Thread(target=callback) thread.start() @@ -113,3 +122,6 @@ class ThreadingManager: def get_wait_dialog(self) -> WaitDialog: return self.wait_dialog + + def show_cancel(self, state: bool) -> None: + GLib.idle_add(self.wait_dialog.show_cancel, state) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 75e45e8..598327a 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -64,7 +64,7 @@ class ServerModCols: @dataclass(slots=True, frozen=True) class MenuCols: name: str - hidden: object # GObject.TYPE_PYOBJECT + hidden: object class FastInsertListStore(ListStore): @@ -108,6 +108,7 @@ class FastInsertListStore(ListStore): self.append(row) def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: + # FIXME: argument cannot be none """ Optimized for speed, but makes no assurances about row homogeneity and may segfault if types and length are not identical to ListStore. diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index ebb3976..642ff12 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -91,6 +91,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(enum, bar) + # TODO: unused def _on_notebook_page_returned( self, statusbar: Self, prior_context: NotebookPage ) -> None: @@ -109,8 +110,9 @@ class Statusbar(Gtk.Grid): context: Union["ServerTab", NotebookPage], ) -> None: self.spinner.stop() + # TODO: CalcDist is being called when table is not loaded if dist is None: - self.set_by_context(context, self.playercount) + self.set_by_context(context, "") else: pretty = self.append_distance(dist) self.set_by_context(context, pretty) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c95d2d0..7e25da2 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -87,7 +87,13 @@ class NotifyDialog(GenericDialog): class WaitDialog(GenericDialog): - def __init__(self, controller: "Controller", secondary: str, jobs: int = 1): + def __init__( + self, + controller: "Controller", + secondary: str, + jobs: int = 1, + show_cancel: bool = False, + ): super().__init__( controller=controller, text=strings.wait, @@ -96,19 +102,29 @@ class WaitDialog(GenericDialog): secondary=secondary, ) + self.controller = controller self.jobs = jobs self.cur_job = 1 + self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER) + self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event()) + self.connect("delete-event", lambda widget, event: True) content = self.get_content_area() spinner = Gtk.Spinner() self.prog = Gtk.ProgressBar() + + content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0) content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) + if self.jobs > 1: content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0) else: spinner.start() + if show_cancel is False: + self.connect("realize", lambda _: self.cancel.set_visible(False)) + def update_text(self, msg: str) -> None: self.format_secondary_text(msg) @@ -119,6 +135,9 @@ class WaitDialog(GenericDialog): self.prog.set_fraction(fraction) self.cur_job += 1 + def show_cancel(self, state: bool) -> None: + self.cancel.set_visible(state) + class QuitDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index c2089ea..fc9f1a1 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -135,7 +135,7 @@ class ServerModDialog(ServerDialog): columns = [ server_mods.mod, server_mods.mod_id, - server_mods.installed, + server_mods.up_to_date, ] for i, column_title in enumerate(columns): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index a354faf..c57b5c1 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -203,8 +203,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): child.set_visible(True) self.raise_window.set_visible(False) - # TODO: enable button if wmctrl or xdotool is available - # TODO: check this at boot time and pass in via connection manager self.raise_window.set_sensitive(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -228,8 +226,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): warnings: list[str] = [] errors: list[str] = [] - # TODO: check availability of currently selected steam cmd - """Errors""" if prereqs.binary_missing: errors.append( @@ -273,6 +269,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.error_placeholder.set_visible(False) self.ok.set_sensitive(False) + def _hide_mod_area(self) -> None: + self.scrolled.set_visible(False) + self.progress_box.set_visible(False) + self.mods_placeholder.set_visible(True) + + def _show_mod_area(self) -> None: + self.scrolled.set_visible(True) + self.progress_box.set_visible(True) + self.mods_placeholder.set_visible(False) + self.tree.grab_focus() + def populate(self, prereqs: "Prerequisites") -> None: mods = prereqs.mods self.tree.populate(mods) @@ -281,27 +288,23 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): name = prereqs.name self.title.set_text(name) - if prereqs.foreground_cmd is not None: - self.raise_window.set_sensitive(True) + suffix = "All mods are up to date." - if prereqs.missing_mods < 1: - self.ok.set_label(preconnect.connect) if total_mods < 1: - self.scrolled.set_visible(False) - self.progress_box.set_visible(False) - self.mods_placeholder.set_visible(True) + self._hide_mod_area() else: - self.scrolled.set_visible(True) - self.progress_box.set_visible(True) - self.raise_window.set_visible(True) - self.mods_placeholder.set_visible(False) - self.tree.grab_focus() + self._show_mod_area() - # TODO: strings - suffix = "All mods are up to date." - if prereqs.required_space > 0: - pretty = number(prereqs.required_space) - suffix = f" Need to download {pretty} MiB of mod updates." + if prereqs.required_space == 0: + self.ok.set_label(preconnect.connect) + self.raise_window.set_visible(False) + else: + self.raise_window.set_visible(True) + if prereqs.foreground_cmd is not None: + self.raise_window.set_sensitive(True) + + pretty = number(prereqs.required_space) + suffix = f" Need to download {pretty} MiB of mod updates." prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")