Merge pull request 'UI: Added tray icon' (#831) from Qubasa-main into main
All checks were successful
checks / test (push) Successful in 30s
checks-impure / test (push) Successful in 1m38s

This commit is contained in:
clan-bot 2024-02-12 07:19:59 +00:00
commit 33787a6aab
11 changed files with 1312 additions and 72 deletions

View File

@ -177,7 +177,7 @@ class Machine:
[ [
"--impure", "--impure",
"--expr", "--expr",
f'(builtins.fetchTree {{ type = "file"; url = "{config_json.name}"; }}).narHash', f'(builtins.fetchTree {{ type = "file"; url = "file://{config_json.name}"; }}).narHash',
] ]
) )
).stdout.strip() ).stdout.strip()

View File

@ -12,7 +12,6 @@ from collections.abc import Iterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import IO
from ..cmd import Log, run from ..cmd import Log, run
from ..dirs import machine_gcroot, module_root, user_cache_dir, vm_state_dir from ..dirs import machine_gcroot, module_root, user_cache_dir, vm_state_dir
@ -147,9 +146,7 @@ def qemu_command(
# TODO move this to the Machines class # TODO move this to the Machines class
def get_vm_create_info( def build_vm(machine: Machine, vm: VmConfig, nix_options: list[str]) -> dict[str, str]:
machine: Machine, vm: VmConfig, nix_options: list[str]
) -> dict[str, str]:
config = nix_config() config = nix_config()
system = config["system"] system = config["system"]
@ -272,19 +269,12 @@ def start_waypipe(cid: int | None, title_prefix: str) -> Iterator[None]:
proc.kill() proc.kill()
def run_vm( def run_vm(vm: VmConfig, nix_options: list[str] = []) -> None:
vm: VmConfig,
nix_options: list[str] = [],
log_fd: IO[str] | None = None,
) -> None:
"""
log_fd can be used to stream the output of all commands to a UI
"""
machine = Machine(vm.machine_name, vm.flake_url) machine = Machine(vm.machine_name, vm.flake_url)
log.debug(f"Creating VM for {machine}") log.debug(f"Creating VM for {machine}")
# TODO: We should get this from the vm argument # TODO: We should get this from the vm argument
nixos_config = get_vm_create_info(machine, vm, nix_options) nixos_config = build_vm(machine, vm, nix_options)
# store the temporary rootfs inside XDG_CACHE_HOME on the host # store the temporary rootfs inside XDG_CACHE_HOME on the host
# otherwise, when using /tmp, we risk running out of memory # otherwise, when using /tmp, we risk running out of memory

View File

@ -26,7 +26,6 @@ def test_history_add(
"add", "add",
str(uri), str(uri),
] ]
cli.run(cmd) cli.run(cmd)
history_file = user_history_file() history_file = user_history_file()

View File

@ -6,6 +6,8 @@ from .app import MainApplication
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# TODO: Trayicon support
# https://github.com/nicotine-plus/nicotine-plus/blob/b08552584eb6f35782ad77da93ae4aae3362bf64/pynicotine/gtkgui/widgets/trayicon.py#L982
def main() -> None: def main() -> None:
app = MainApplication() app = MainApplication()
return app.run(sys.argv) return app.run(sys.argv)

View File

@ -16,6 +16,7 @@ from clan_vm_manager.models.interfaces import ClanConfig
from clan_vm_manager.models.use_join import GLib, GObject 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 from .windows.main_window import MainWindow
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -29,9 +30,11 @@ class MainApplication(Adw.Application):
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__( super().__init__(
*args, *args,
application_id="lol.clan.vm.manager",
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
**kwargs, **kwargs,
) )
self.tray_icon: TrayIcon | None = None
self.add_main_option( self.add_main_option(
"debug", "debug",
@ -42,7 +45,7 @@ class MainApplication(Adw.Application):
None, None,
) )
self.win: Adw.ApplicationWindow | None = None self.window: Adw.ApplicationWindow | None = None
self.connect("shutdown", self.on_shutdown) self.connect("shutdown", self.on_shutdown)
self.connect("activate", self.show_window) self.connect("activate", self.show_window)
@ -70,19 +73,33 @@ class MainApplication(Adw.Application):
def on_shutdown(self, app: Gtk.Application) -> None: def on_shutdown(self, app: Gtk.Application) -> None:
log.debug("Shutting down") log.debug("Shutting down")
if self.tray_icon is not None:
self.tray_icon.destroy()
VMS.use().kill_all() 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()
def dummy_menu_entry(self) -> None:
log.info("Dummy menu entry called")
def do_activate(self) -> None: def do_activate(self) -> None:
self.show_window() self.show_window()
def show_window(self, app: Any = None) -> None: def show_window(self, app: Any = None) -> None:
if not self.win: if not self.window:
self.init_style() self.init_style()
self.win = MainWindow(config=ClanConfig(initial_view="list")) self.window = MainWindow(config=ClanConfig(initial_view="list"))
self.win.set_application(self) self.window.set_application(self)
icon_path = assets.loc / "clan_black.png" self.tray_icon = TrayIcon(self)
self.win.set_default_icon_name(str(icon_path)) self.window.present()
self.win.present()
# TODO: For css styling # TODO: For css styling
def init_style(self) -> None: def init_style(self) -> None:

View File

@ -1,5 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
import gi import gi
@ -9,8 +8,3 @@ gi.require_version("Gtk", "4.0")
@dataclass @dataclass
class ClanConfig: class ClanConfig:
initial_view: str initial_view: str
class VMStatus(StrEnum):
RUNNING = "Running"
STOPPED = "Stopped"

View File

@ -1,6 +1,7 @@
import os import os
import tempfile import tempfile
import weakref import weakref
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import IO, Any, ClassVar from typing import IO, Any, ClassVar
@ -13,7 +14,6 @@ from clan_cli.history.list import list_history
from clan_vm_manager import assets from clan_vm_manager import assets
from clan_vm_manager.errors.show_error import show_error_dialog from clan_vm_manager.errors.show_error import show_error_dialog
from clan_vm_manager.models.interfaces import VMStatus
from .executor import MPProcess, spawn from .executor import MPProcess, spawn
@ -98,27 +98,26 @@ class VM(GObject.Object):
# Define a custom signal with the name "vm_stopped" and a string argument for the message # Define a custom signal with the name "vm_stopped" and a string argument for the message
__gsignals__: ClassVar = { __gsignals__: ClassVar = {
"vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]), "vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]),
"build_vm": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object, bool]),
} }
def __init__( def __init__(
self, self,
icon: Path, icon: Path,
status: VMStatus,
data: HistoryEntry, data: HistoryEntry,
) -> None: ) -> None:
super().__init__() super().__init__()
self.data = data self.data = data
self.process = MPProcess("dummy", mp.Process(), Path("./dummy")) self.process = MPProcess("dummy", mp.Process(), Path("./dummy"))
self._watcher_id: int = 0 self._watcher_id: int = 0
self._stop_watcher_id: int = 0
self._stop_timer_init: datetime | None = None
self._logs_id: int = 0 self._logs_id: int = 0
self._log_file: IO[str] | None = None self._log_file: IO[str] | None = None
self.status = status
self._last_liveness: bool = False
self.log_dir = tempfile.TemporaryDirectory( self.log_dir = tempfile.TemporaryDirectory(
prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}" prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}"
) )
self._finalizer = weakref.finalize(self, self.stop) self._finalizer = weakref.finalize(self, self.stop)
self.connect("vm_status_changed", self._start_logs_task)
uri = ClanURI.from_str( uri = ClanURI.from_str(
url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr
@ -136,50 +135,47 @@ class VM(GObject.Object):
) )
def __start(self) -> None: def __start(self) -> None:
if self.is_running(): log.info(f"Starting VM {self.get_id()}")
log.warn("VM is already running")
return
vm = vms.run.inspect_vm(self.machine) vm = vms.run.inspect_vm(self.machine)
GLib.idle_add(self.emit, "build_vm", self, True)
vms.run.build_vm(self.machine, vm, [])
GLib.idle_add(self.emit, "build_vm", self, False)
self.process = spawn( self.process = spawn(
on_except=None, on_except=None,
log_dir=Path(str(self.log_dir.name)), log_dir=Path(str(self.log_dir.name)),
func=vms.run.run_vm, func=vms.run.run_vm,
vm=vm, vm=vm,
) )
log.debug("Starting VM") log.debug(f"Started VM {self.get_id()}")
GLib.idle_add(self.emit, "vm_status_changed", self)
log.debug(f"Starting logs watcher on file: {self.process.out_file}")
self._logs_id = GLib.timeout_add(50, self._get_logs_task)
if self._logs_id == 0:
raise ClanError("Failed to add logs watcher")
log.debug(f"Starting VM watcher for: {self.machine.name}")
self._watcher_id = GLib.timeout_add(50, self._vm_watcher_task)
if self._watcher_id == 0:
raise ClanError("Failed to add watcher")
self.machine.qmp_connect() self.machine.qmp_connect()
def start(self) -> None: def start(self) -> None:
if self.is_running(): if self.is_running():
log.warn("VM is already running") log.warn("VM is already running")
return return
threading.Thread(target=self.__start).start() threading.Thread(target=self.__start).start()
# Every 50ms check if the VM is still running
self._watcher_id = GLib.timeout_add(50, self._vm_watcher_task)
if self._watcher_id == 0:
raise ClanError("Failed to add watcher")
def _vm_watcher_task(self) -> bool: def _vm_watcher_task(self) -> bool:
if self.is_running() != self._last_liveness: if not self.is_running():
self.emit("vm_status_changed", self) self.emit("vm_status_changed", self)
prev_liveness = self._last_liveness log.debug("Removing VM watcher")
self._last_liveness = self.is_running() return GLib.SOURCE_REMOVE
# If the VM was running and now it is not, remove the watcher
if prev_liveness and not self.is_running():
log.debug("Removing VM watcher")
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE return GLib.SOURCE_CONTINUE
def _start_logs_task(self, obj: Any, vm: Any) -> None:
if self.is_running():
log.debug(f"Starting logs watcher on file: {self.process.out_file}")
self._logs_id = GLib.timeout_add(50, self._get_logs_task)
else:
log.debug("Not starting logs watcher")
def _get_logs_task(self) -> bool: def _get_logs_task(self) -> bool:
if not self.process.out_file.exists(): if not self.process.out_file.exists():
return GLib.SOURCE_CONTINUE return GLib.SOURCE_CONTINUE
@ -192,15 +188,15 @@ class VM(GObject.Object):
self._log_file = None self._log_file = None
return GLib.SOURCE_REMOVE return GLib.SOURCE_REMOVE
line = os.read(self._log_file.fileno(), 4096)
if len(line) != 0:
print(line.decode("utf-8"), end="", flush=True)
if not self.is_running(): if not self.is_running():
log.debug("Removing logs watcher") log.debug("Removing logs watcher")
self._log_file = None self._log_file = None
return GLib.SOURCE_REMOVE return GLib.SOURCE_REMOVE
line = os.read(self._log_file.fileno(), 4096)
if len(line) != 0:
print(line.decode("utf-8"), end="", flush=True)
return GLib.SOURCE_CONTINUE return GLib.SOURCE_CONTINUE
def is_running(self) -> bool: def is_running(self) -> bool:
@ -209,12 +205,32 @@ class VM(GObject.Object):
def get_id(self) -> str: def get_id(self) -> str:
return f"{self.data.flake.flake_url}#{self.data.flake.flake_attr}" return f"{self.data.flake.flake_url}#{self.data.flake.flake_attr}"
def __shutdown_watchdog(self) -> None:
if self.is_running():
assert self._stop_timer_init is not None
diff = datetime.now() - self._stop_timer_init
if diff.seconds > 10:
log.error(f"VM {self.get_id()} has not stopped. Killing it")
self.process.kill_group()
return GLib.SOURCE_CONTINUE
else:
log.info(f"VM {self.get_id()} has stopped")
return GLib.SOURCE_REMOVE
def __stop(self) -> None:
log.info(f"Stopping VM {self.get_id()}")
self.machine.qmp_command("system_powerdown")
self._stop_timer_init = datetime.now()
self._stop_watcher_id = GLib.timeout_add(100, self.__shutdown_watchdog)
if self._stop_watcher_id == 0:
raise ClanError("Failed to add stop watcher")
def stop(self) -> None: def stop(self) -> None:
if not self.is_running(): if not self.is_running():
return return
log.info(f"Stopping VM {self.get_id()}") log.info(f"Stopping VM {self.get_id()}")
# TODO: add fallback to kill the process if the QMP command fails threading.Thread(target=self.__stop).start()
self.machine.qmp_command("system_powerdown")
def read_whole_log(self) -> str: def read_whole_log(self) -> str:
if not self.process.out_file.exists(): if not self.process.out_file.exists():
@ -296,7 +312,6 @@ def get_saved_vms() -> list[VM]:
base = VM( base = VM(
icon=Path(icon), icon=Path(icon),
status=VMStatus.STOPPED,
data=entry, data=entry,
) )
vm_list.append(base) vm_list.append(base)

File diff suppressed because it is too large Load Diff

View File

@ -100,12 +100,10 @@ class ClanList(Gtk.Box):
box.set_valign(Gtk.Align.CENTER) box.set_valign(Gtk.Align.CENTER)
add_button = Gtk.MenuButton() add_button = Gtk.MenuButton()
add_button.set_icon_name("list-add")
add_button.set_has_frame(False) add_button.set_has_frame(False)
add_button.set_menu_model(menu_model) add_button.set_menu_model(menu_model)
add_button.set_label("Add machine")
box.append(add_button) box.append(add_button)
box.append(Gtk.Label.new("Add machine"))
grp.set_header_suffix(box) grp.set_header_suffix(box)
@ -192,6 +190,7 @@ class ClanList(Gtk.Box):
switch.connect("notify::active", partial(self.on_row_toggle, vm)) switch.connect("notify::active", partial(self.on_row_toggle, vm))
vm.connect("vm_status_changed", partial(self.vm_status_changed, switch)) vm.connect("vm_status_changed", partial(self.vm_status_changed, switch))
vm.connect("build_vm", self.build_vm)
# suffix.append(box) # suffix.append(box)
row.add_suffix(box) row.add_suffix(box)
@ -295,6 +294,12 @@ class ClanList(Gtk.Box):
row.set_state(True) row.set_state(True)
vm.stop() vm.stop()
def build_vm(self, vm: VM, _vm: VM, building: bool) -> None:
if building:
log.info("Building VM")
else:
log.info("VM built")
def vm_status_changed(self, switch: Gtk.Switch, vm: VM, _vm: VM) -> None: def vm_status_changed(self, switch: Gtk.Switch, vm: VM, _vm: VM) -> None:
switch.set_active(vm.is_running()) switch.set_active(vm.is_running())
switch.set_state(vm.is_running()) switch.set_state(vm.is_running())

View File

@ -57,7 +57,7 @@ python3.pkgs.buildPythonApplication {
''; '';
desktopItems = [ desktopItems = [
(makeDesktopItem { (makeDesktopItem {
name = "clan-vm-manager"; name = "lol.clan.vm.manager";
exec = "clan-vm-manager %u"; exec = "clan-vm-manager %u";
icon = ./clan_vm_manager/assets/clan_white.png; icon = ./clan_vm_manager/assets/clan_white.png;
desktopName = "cLAN Manager"; desktopName = "cLAN Manager";

View File

@ -20,18 +20,18 @@ mkShell {
ln -snf ${clan-vm-manager} result ln -snf ${clan-vm-manager} result
# install desktop file # install desktop file
set -eou pipefail set -eou pipefail
DESKTOP_DST=~/.local/share/applications/clan-vm-manager.desktop DESKTOP_FILE_NAME=lol.clan.vm.manager.desktop
DESKTOP_SRC=${clan-vm-manager}/share/applications/clan-vm-manager.desktop DESKTOP_DST=~/.local/share/applications/$DESKTOP_FILE_NAME
DESKTOP_SRC=${clan-vm-manager}/share/applications/$DESKTOP_FILE_NAME
# UI_BIN="env GTK_DEBUG=interactive ${clan-vm-manager}/bin/clan-vm-manager" # UI_BIN="env GTK_DEBUG=interactive ${clan-vm-manager}/bin/clan-vm-manager"
UI_BIN="${clan-vm-manager}/bin/clan-vm-manager" UI_BIN="${clan-vm-manager}/bin/clan-vm-manager"
cp -f $DESKTOP_SRC $DESKTOP_DST cp -f $DESKTOP_SRC $DESKTOP_DST
sleep 2 sleep 2
sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" $DESKTOP_DST sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" $DESKTOP_DST
xdg-mime default clan-vm-manager.desktop x-scheme-handler/clan xdg-mime default $DESKTOP_FILE_NAME x-scheme-handler/clan
echo "==== Validating desktop file installation ====" echo "==== Validating desktop file installation ===="
set -x set -x
desktop-file-validate $DESKTOP_DST desktop-file-validate $DESKTOP_DST