diff --git a/pkgs/clan-cli/clan_cli/machines/machines.py b/pkgs/clan-cli/clan_cli/machines/machines.py index 8c1d7d55..bab07ea3 100644 --- a/pkgs/clan-cli/clan_cli/machines/machines.py +++ b/pkgs/clan-cli/clan_cli/machines/machines.py @@ -18,17 +18,23 @@ log = logging.getLogger(__name__) class VMAttr: def __init__(self, state_dir: Path) -> None: + # These sockets here are just symlinks to the real sockets which + # are created by the run.py file. The reason being that we run into + # file path length issues on Linux. If no qemu process is running + # the symlink will be dangling. self._qmp_socket: Path = state_dir / "qmp.sock" self._qga_socket: Path = state_dir / "qga.sock" self._qmp: QEMUMonitorProtocol | None = None @contextmanager - def qmp(self) -> Generator[QEMUMonitorProtocol, None, None]: + def qmp_ctx(self) -> Generator[QEMUMonitorProtocol, None, None]: if self._qmp is None: log.debug(f"qmp_socket: {self._qmp_socket}") rpath = self._qmp_socket.resolve() if not rpath.exists(): - raise ClanError(f"qmp socket {rpath} does not exist") + raise ClanError( + f"qmp socket {rpath} does not exist. Is the VM running?" + ) self._qmp = QEMUMonitorProtocol(str(rpath)) self._qmp.connect() try: diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index cac5ab9a..abeb28f9 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -37,7 +37,7 @@ def facts_to_nixos_config(facts: dict[str, dict[str, bytes]]) -> dict: # TODO move this to the Machines class def build_vm( - machine: Machine, vm: VmConfig, tmpdir: Path, nix_options: list[str] + machine: Machine, vm: VmConfig, tmpdir: Path, nix_options: list[str] = [] ) -> dict[str, str]: secrets_dir = get_secrets(machine, tmpdir) diff --git a/pkgs/clan-cli/tests/test_vms_cli.py b/pkgs/clan-cli/tests/test_vms_cli.py index 6e272098..9c2700ad 100644 --- a/pkgs/clan-cli/tests/test_vms_cli.py +++ b/pkgs/clan-cli/tests/test_vms_cli.py @@ -43,7 +43,9 @@ def wait_vm_up(state_dir: Path) -> None: timeout: float = 300 while True: if timeout <= 0: - raise TimeoutError(f"qga socket {socket_file} not found") + raise TimeoutError( + f"qga socket {socket_file} not found. Is the VM running?" + ) if socket_file.exists(): break sleep(0.1) @@ -56,7 +58,9 @@ def wait_vm_down(state_dir: Path) -> None: timeout: float = 300 while socket_file.exists(): if timeout <= 0: - raise TimeoutError(f"qga socket {socket_file} still exists") + raise TimeoutError( + f"qga socket {socket_file} still exists. Is the VM down?" + ) sleep(0.1) timeout -= 0.1 diff --git a/pkgs/clan-vm-manager/README.md b/pkgs/clan-vm-manager/README.md index bc8e04b0..fc966750 100644 --- a/pkgs/clan-vm-manager/README.md +++ b/pkgs/clan-vm-manager/README.md @@ -11,6 +11,10 @@ GTK4 has a demo application showing all widgets. You can run it by executing: gtk4-widget-factory ``` +To find available icons execute: +```bash +gtk4-icon-browser +``` diff --git a/pkgs/clan-vm-manager/clan_vm_manager/app.py b/pkgs/clan-vm-manager/clan_vm_manager/app.py index fe1ba4f3..48c0fb14 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/app.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/app.py @@ -14,7 +14,7 @@ from gi.repository import Adw, Gdk, Gio, Gtk from clan_vm_manager.models.interfaces import ClanConfig from clan_vm_manager.models.use_join import GLib, GObject -from clan_vm_manager.models.use_vms import VMS +from clan_vm_manager.models.use_vms import VMs from .trayicon import TrayIcon from .windows.main_window import MainWindow @@ -44,7 +44,8 @@ class MainApplication(Adw.Application): "enable debug mode", None, ) - + self.vms = VMs.use() + log.debug(f"VMS object: {self.vms}") self.window: Adw.ApplicationWindow | None = None self.connect("shutdown", self.on_shutdown) self.connect("activate", self.show_window) @@ -69,24 +70,22 @@ class MainApplication(Adw.Application): log.debug(f"Join request: {args[1]}") uri = args[1] self.emit("join_request", uri) - return 0 def on_shutdown(self, app: Gtk.Application) -> None: log.debug("Shutting down") + self.vms.kill_all() + if self.tray_icon is not None: self.tray_icon.destroy() - VMS.use().kill_all() - def on_window_hide_unhide(self, *_args: Any) -> None: assert self.window is not None if self.window.is_visible(): self.window.hide() - return - - self.window.present() + else: + self.window.present() def dummy_menu_entry(self) -> None: log.info("Dummy menu entry called") diff --git a/pkgs/clan-vm-manager/clan_vm_manager/assets/clan_white_notext.png b/pkgs/clan-vm-manager/clan_vm_manager/assets/clan_white_notext.png new file mode 100644 index 00000000..45361f1a Binary files /dev/null and b/pkgs/clan-vm-manager/clan_vm_manager/assets/clan_white_notext.png differ diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/executor.py b/pkgs/clan-vm-manager/clan_vm_manager/models/executor.py index dfc56832..5987a9a1 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/executor.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/executor.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any import gi -from clan_cli.errors import ClanError gi.require_version("GdkPixbuf", "2.0") @@ -24,7 +23,7 @@ def _kill_group(proc: mp.Process) -> None: if proc.is_alive() and pid: os.killpg(pid, signal.SIGTERM) else: - log.warning(f"Process {proc.name} with pid {pid} is already dead") + log.warning(f"Process '{proc.name}' with pid '{pid}' is already dead") @dataclasses.dataclass(frozen=True) @@ -102,7 +101,7 @@ def _init_proc( def spawn( *, - log_dir: Path, + out_file: Path, on_except: Callable[[Exception, mp.process.BaseProcess], None] | None, func: Callable, **kwargs: Any, @@ -111,13 +110,8 @@ def spawn( if mp.get_start_method(allow_none=True) is None: mp.set_start_method(method="forkserver") - if not log_dir.is_dir(): - raise ClanError(f"Log path {log_dir} is not a directory") - log_dir.mkdir(parents=True, exist_ok=True) - # Set names proc_name = f"MPExec:{func.__name__}" - out_file = log_dir / "out.log" # Start the process proc = mp.Process( diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py index cba131e7..88215b1e 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py @@ -9,7 +9,7 @@ from clan_cli.clan_uri import ClanURI from clan_cli.history.add import add_history from clan_vm_manager.errors.show_error import show_error_dialog -from clan_vm_manager.models.use_vms import VMS, Clans +from clan_vm_manager.models.use_vms import Clans gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -76,7 +76,7 @@ class Join: def after_join(item: JoinValue, _: Any) -> None: self.discard(item) Clans.use().refresh() - VMS.use().refresh() + # VMS.use().refresh() print("Refreshed list after join") on_join(item) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py index 4f5f216c..fbefec06 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py @@ -107,6 +107,7 @@ class VM(GObject.Object): data: HistoryEntry, ) -> None: super().__init__() + self.KILL_TIMEOUT = 6 # seconds self.data = data self.process = MPProcess("dummy", mp.Process(), Path("./dummy")) self._watcher_id: int = 0 @@ -121,9 +122,8 @@ class VM(GObject.Object): self.log_dir = tempfile.TemporaryDirectory( prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}" ) - self._finalizer = weakref.finalize(self, self.stop) + self._finalizer = weakref.finalize(self, self.kill) self.connect("build_vm", self.build_vm) - uri = ClanURI.from_str( url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr ) @@ -160,25 +160,25 @@ class VM(GObject.Object): log.info(f"Starting VM {self.get_id()}") vm = vms.run.inspect_vm(self.machine) - GLib.idle_add(self.emit, "build_vm", self, True) - self.process = spawn( - on_except=None, - log_dir=Path(str(self.log_dir.name)), - func=vms.run.build_vm, - machine=self.machine, - vm=vm, - ) - self.process.proc.join() + # GLib.idle_add(self.emit, "build_vm", self, True) + # self.process = spawn( + # on_except=None, + # log_dir=Path(str(self.log_dir.name)), + # func=vms.run.build_vm, + # machine=self.machine, + # vm=vm, + # ) + # self.process.proc.join() - GLib.idle_add(self.emit, "build_vm", self, False) + # GLib.idle_add(self.emit, "build_vm", self, False) - if self.process.proc.exitcode != 0: - log.error(f"Failed to build VM {self.get_id()}") - return + # if self.process.proc.exitcode != 0: + # log.error(f"Failed to build VM {self.get_id()}") + # return self.process = spawn( on_except=None, - log_dir=Path(str(self.log_dir.name)), + out_file=Path(str(self.log_dir.name)) / "vm.log", func=vms.run.run_vm, vm=vm, ) @@ -241,7 +241,7 @@ class VM(GObject.Object): if self.is_running(): assert self._stop_timer_init is not None diff = datetime.now() - self._stop_timer_init - if diff.seconds > 10: + if diff.seconds > self.KILL_TIMEOUT: log.error(f"VM {self.get_id()} has not stopped. Killing it") self.process.kill_group() return GLib.SOURCE_CONTINUE @@ -253,7 +253,7 @@ class VM(GObject.Object): log.info(f"Stopping VM {self.get_id()}") try: - with self.machine.vm.qmp() as qmp: + with self.machine.vm.qmp_ctx() as qmp: qmp.command("system_powerdown") except ClanError as e: log.debug(e) @@ -263,12 +263,19 @@ class VM(GObject.Object): if self._stop_watcher_id == 0: raise ClanError("Failed to add stop watcher") - def stop(self) -> None: + def shutdown(self) -> None: if not self.is_running(): return log.info(f"Stopping VM {self.get_id()}") threading.Thread(target=self.__stop).start() + def kill(self) -> None: + if not self.is_running(): + log.warning(f"Tried to kill VM {self.get_id()} is not running") + return + log.info(f"Killing VM {self.get_id()} now") + self.process.kill_group() + def read_whole_log(self) -> str: if not self.process.out_file.exists(): log.error(f"Log file {self.process.out_file} does not exist") @@ -276,28 +283,16 @@ class VM(GObject.Object): return self.process.out_file.read_text() -class VMS: - """ - This is a singleton. - It is initialized with the first call of use() - - Usage: - - VMS.use().get_running_vms() - - VMS.use() can also be called before the data is needed. e.g. to eliminate/reduce waiting time. - - """ - +class VMs: list_store: Gio.ListStore - _instance: "None | VMS" = None + _instance: "None | VMs" = None # Make sure the VMS class is used as a singleton def __init__(self) -> None: raise RuntimeError("Call use() instead") @classmethod - def use(cls: Any) -> "VMS": + def use(cls: Any) -> "VMs": if cls._instance is None: cls._instance = cls.__new__(cls) cls.list_store = Gio.ListStore.new(VM) @@ -327,10 +322,13 @@ class VMS: return list(filter(lambda vm: vm.is_running(), self.list_store)) def kill_all(self) -> None: + log.debug(f"Running vms: {self.get_running_vms()}") for vm in self.get_running_vms(): - vm.stop() + vm.kill() def refresh(self) -> None: + log.error("NEVER FUCKING DO THIS") + return self.list_store.remove_all() for vm in get_saved_vms(): self.list_store.append(vm) @@ -338,7 +336,7 @@ class VMS: def get_saved_vms() -> list[VM]: vm_list = [] - + log.info("=====CREATING NEW VM OBJ====") try: # Execute `clan flakes add ` to democlan for this to work for entry in list_history(): diff --git a/pkgs/clan-vm-manager/clan_vm_manager/trayicon.py b/pkgs/clan-vm-manager/clan_vm_manager/trayicon.py index f827a608..ceb4fc31 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/trayicon.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/trayicon.py @@ -172,7 +172,7 @@ class BaseImplementation: self.application = application self.menu_items: dict[int, Any] = {} self.menu_item_id: int = 1 - self.activate_callback: Callable = application.on_window_hide_unhide + self.activate_callback: Callable = lambda a, b: self.update_window_visibility self.is_visible: bool = True self.create_menu() @@ -213,16 +213,12 @@ class BaseImplementation: def create_menu(self) -> None: self.show_hide_item = self.create_item( - "default", self.application.dummy_menu_entry + "default", self.application.on_window_hide_unhide ) - self.connect_disconnect_item = self.create_item( - "default", self.application.dummy_menu_entry - ) + # self.create_item() - self.create_item() - - self.create_item("_Quit", self.application.dummy_menu_entry) + # self.create_item("_Quit", self.application.on_shutdown) def update_window_visibility(self) -> None: if self.application.window is None: @@ -237,14 +233,6 @@ class BaseImplementation: self.update_menu() def update_user_status(self) -> None: - sensitive = core.users.login_status != slskmessages.UserStatus.OFFLINE - label = "_Disconnect" if sensitive else "_Connect" - - # self.set_item_sensitive(self.away_item, sensitive) - - self.set_item_text(self.connect_disconnect_item, label) - # self.set_item_toggled(self.away_item, core.users.login_status == slskmessages.UserStatus.AWAY) - self.update_icon() self.update_menu() @@ -266,9 +254,9 @@ class BaseImplementation: # icon_name = "disconnect" # icon_name = f"{pynicotine.__application_id__}-{icon_name}" - # self.set_icon_name(icon_name) + # self.set_icon(icon_name) - def set_icon_name(self, icon_name: str) -> None: + def set_icon(self, icon_name: str) -> None: # Implemented in subclasses pass @@ -410,6 +398,7 @@ class StatusNotifierImplementation(BaseImplementation): ): method = self.methods[method_name] result = method.callback(*parameters.unpack()) + out_arg_types = "".join(method.out_args) return_value = None @@ -570,6 +559,11 @@ class StatusNotifierImplementation(BaseImplementation): ) self.tray_icon.register() + from .assets import loc + + icon_path = str(loc / "clan_white_notext.png") + self.set_icon(icon_path) + self.bus.call_sync( bus_name="org.kde.StatusNotifierWatcher", object_path="/StatusNotifierWatcher", @@ -617,38 +611,12 @@ class StatusNotifierImplementation(BaseImplementation): """Returns an icon path to use for tray icons, or None to fall back to system-wide icons.""" - self.custom_icons = False - custom_icon_path = os.path.join(config.data_folder_path, ".nicotine-icon-theme") - - if hasattr(sys, "real_prefix") or sys.base_prefix != sys.prefix: - # Virtual environment - local_icon_path = os.path.join( - sys.prefix, "share", "icons", "hicolor", "scalable", "apps" - ) - else: - # Git folder - local_icon_path = os.path.join( - GTK_GUI_FOLDER_PATH, "icons", "hicolor", "scalable", "apps" - ) - - for icon_name in ("away", "connect", "disconnect", "msg"): - # Check if custom icons exist - if self.check_icon_path(icon_name, custom_icon_path): - self.custom_icons = True - return custom_icon_path - - # Check if local icons exist - if self.check_icon_path(icon_name, local_icon_path): - return local_icon_path + # icon_path = self.application.get_application_icon_path() return "" - def set_icon_name(self, icon_name): - if self.custom_icons: - # Use alternative icon names to enforce custom icons, since system-wide icons take precedence - icon_name = icon_name.replace(pynicotine.__application_id__, "nplus-tray") - - self.tray_icon.properties["IconName"].value = icon_name + def set_icon(self, icon_path) -> None: + self.tray_icon.properties["IconName"].value = icon_path self.tray_icon.emit_signal("NewIcon") if not self.is_visible: @@ -1064,7 +1032,7 @@ class Win32Implementation(BaseImplementation): self._menu, item_id, False, byref(item_info) ) - def set_icon_name(self, icon_name): + def set_icon(self, icon_name): self._update_notify_icon(icon_name=icon_name) def show_notification(self, title, message): diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py index b28f14ce..ed529675 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -14,7 +14,7 @@ from clan_vm_manager.models.use_views import Views gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk -from clan_vm_manager.models.use_vms import VM, VMS, ClanGroup, Clans +from clan_vm_manager.models.use_vms import VM, ClanGroup, Clans log = logging.getLogger(__name__) @@ -48,8 +48,8 @@ class ClanList(Gtk.Box): def __init__(self, config: ClanConfig) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - app = Gio.Application.get_default() - app.connect("join_request", self.on_join_request) + self.app = Gio.Application.get_default() + self.app.connect("join_request", self.on_join_request) groups = Clans.use() join = Join.use() @@ -123,7 +123,7 @@ class ClanList(Gtk.Box): def on_search_changed(self, entry: Gtk.SearchEntry) -> None: Clans.use().filter_by_name(entry.get_text()) # Disable the shadow if the list is empty - if not VMS.use().list_store.get_n_items(): + if not self.app.vms.list_store.get_n_items(): self.group_list.add_css_class("no-shadow") def render_vm_row(self, boxed_list: Gtk.ListBox, vm: VM) -> Gtk.Widget: @@ -202,7 +202,7 @@ class ClanList(Gtk.Box): def on_edit(self, action: Any, parameter: Any) -> None: target = parameter.get_string() - vm = VMS.use().get_by_id(target) + vm = self.app.vms.get_by_id(target) if not vm: raise ClanError("Something went wrong. Please restart the app.") @@ -220,7 +220,7 @@ class ClanList(Gtk.Box): row.add_css_class("trust") # TODO: figure out how to detect that - exist = VMS.use().get_by_id(item.url.get_id()) + exist = self.app.vms.use().get_by_id(item.url.get_id()) if exist: sub = row.get_subtitle() row.set_subtitle( @@ -292,7 +292,7 @@ class ClanList(Gtk.Box): if not row.get_active(): row.set_state(True) - vm.stop() + vm.shutdown() def vm_status_changed(self, switch: Gtk.Switch, vm: VM, _vm: VM) -> None: switch.set_active(vm.is_running())