Compare commits
10 Commits
00f7a6300b
...
bf5cc8c215
Author | SHA1 | Date | |
---|---|---|---|
bf5cc8c215 | |||
4d25118853 | |||
c8407e2052 | |||
a294c6d454 | |||
4193d9e9a9 | |||
bb9da50d45 | |||
b1af97f2a5 | |||
c6a0cd9398 | |||
00bad3d614 | |||
ed5dd02879 |
@ -106,6 +106,16 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
options.clan.borgbackup.exclude = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
example = [ "*.pyc" ];
|
||||
default = [ ];
|
||||
description = ''
|
||||
Directories/Files to exclude from the backup.
|
||||
Use * as a wildcard.
|
||||
'';
|
||||
};
|
||||
|
||||
imports = [
|
||||
(lib.mkRemovedOptionModule [
|
||||
"clan"
|
||||
@ -129,7 +139,7 @@ in
|
||||
paths = lib.unique (
|
||||
lib.flatten (map (state: state.folders) (lib.attrValues config.clan.core.state))
|
||||
);
|
||||
exclude = [ "*.pyc" ];
|
||||
exclude = cfg.exclude;
|
||||
repo = dest.repo;
|
||||
environment.BORG_RSH = dest.rsh;
|
||||
compression = "auto,zstd";
|
||||
|
@ -9,9 +9,9 @@
|
||||
users.users.root.hashedPasswordFile =
|
||||
config.clan.core.facts.services.root-password.secret.password-hash.path;
|
||||
|
||||
sops.secrets."${config.clan.core.machineName}-password-hash".neededForUsers = lib.mkIf (
|
||||
config.clan.core.facts.secretStore == "sops"
|
||||
) true;
|
||||
sops.secrets = lib.mkIf (config.clan.core.facts.secretStore == "sops") {
|
||||
"${config.clan.core.machineName}-password-hash".neededForUsers = true;
|
||||
};
|
||||
|
||||
clan.core.facts.services.root-password = {
|
||||
secret.password = { };
|
||||
|
@ -23,7 +23,11 @@
|
||||
users.mutableUsers = false;
|
||||
users.users.${config.clan.user-password.user}.hashedPasswordFile =
|
||||
config.clan.core.facts.services.user-password.secret.user-password-hash.path;
|
||||
sops.secrets."${config.clan.core.machineName}-user-password-hash".neededForUsers = true;
|
||||
|
||||
sops.secrets = lib.mkIf (config.clan.core.facts.secretStore == "sops") {
|
||||
"${config.clan.core.machineName}-user-password-hash".neededForUsers = true;
|
||||
};
|
||||
|
||||
clan.core.facts.services.user-password = {
|
||||
secret.user-password = { };
|
||||
secret.user-password-hash = { };
|
||||
|
@ -17,8 +17,13 @@ let
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/qemu-vm.nix")
|
||||
./serial.nix
|
||||
./waypipe.nix
|
||||
];
|
||||
|
||||
clan.services.waypipe = {
|
||||
inherit (config.clan.core.vm.inspect.waypipe) enable command;
|
||||
};
|
||||
|
||||
# required for issuing shell commands via qga
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
@ -149,12 +154,19 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
waypipe = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to use waypipe for native wayland passthrough, or not.
|
||||
'';
|
||||
waypipe = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to use waypipe for native wayland passthrough, or not.
|
||||
'';
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Commands that waypipe should run";
|
||||
};
|
||||
};
|
||||
};
|
||||
# All important VM config variables needed by the vm runner
|
||||
@ -193,13 +205,22 @@ in
|
||||
whether to enable graphics for the vm
|
||||
'';
|
||||
};
|
||||
waypipe = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
whether to enable native wayland window passthrough with waypipe for the vm
|
||||
'';
|
||||
|
||||
waypipe = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Whether to use waypipe for native wayland passthrough, or not.
|
||||
'';
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
description = "Commands that waypipe should run";
|
||||
};
|
||||
};
|
||||
machine_icon = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
@ -245,7 +266,12 @@ in
|
||||
initrd = "${vmConfig.config.system.build.initialRamdisk}/${vmConfig.config.system.boot.loader.initrdFile}";
|
||||
toplevel = vmConfig.config.system.build.toplevel;
|
||||
regInfo = (pkgs.closureInfo { rootPaths = vmConfig.config.virtualisation.additionalPaths; });
|
||||
inherit (config.clan.virtualisation) memorySize cores graphics;
|
||||
inherit (config.clan.virtualisation)
|
||||
memorySize
|
||||
cores
|
||||
graphics
|
||||
waypipe
|
||||
;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -39,8 +39,6 @@
|
||||
# General default settings
|
||||
fonts.enableDefaultPackages = lib.mkDefault true;
|
||||
hardware.opengl.enable = lib.mkDefault true;
|
||||
# Assume it is run inside a clan context
|
||||
clan.virtualisation.waypipe = lib.mkDefault true;
|
||||
|
||||
# User account
|
||||
services.getty.autologinUser = lib.mkDefault config.clan.services.waypipe.user;
|
@ -34,7 +34,7 @@ class MainApplication(Adw.Application):
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(
|
||||
application_id="org.clan.clan-app",
|
||||
application_id="org.clan.app",
|
||||
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
|
||||
)
|
||||
|
||||
|
@ -39,6 +39,7 @@ class BorgbackupConfigDestination:
|
||||
@dataclass
|
||||
class BorgbackupConfig:
|
||||
destinations: dict[str, BorgbackupConfigDestination] = field(default_factory = dict)
|
||||
exclude: list[str] = field(default_factory = list)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -246,6 +246,7 @@ class Machine:
|
||||
@attr: the attribute to get
|
||||
"""
|
||||
|
||||
log.warning(f"Extra config: {extra_config}")
|
||||
if attr in self._build_cache and not refresh and extra_config is None:
|
||||
return self._build_cache[attr]
|
||||
|
||||
|
@ -8,6 +8,12 @@ from ..completions import add_dynamic_completer, complete_machines
|
||||
from ..machines.machines import Machine
|
||||
|
||||
|
||||
@dataclass
|
||||
class WaypipeConfig:
|
||||
enable: bool
|
||||
command: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VmConfig:
|
||||
machine_name: str
|
||||
@ -24,6 +30,8 @@ class VmConfig:
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.flake_url, str):
|
||||
self.flake_url = FlakeId(self.flake_url)
|
||||
if isinstance(self.waypipe, dict):
|
||||
self.waypipe = WaypipeConfig(**self.waypipe)
|
||||
|
||||
|
||||
def inspect_vm(machine: Machine) -> VmConfig:
|
||||
|
@ -56,6 +56,7 @@ def generate_flake(
|
||||
},
|
||||
# define the machines directly including their config
|
||||
machine_configs: dict[str, dict] = {},
|
||||
inventory: dict[str, dict] = {},
|
||||
) -> FlakeForTest:
|
||||
"""
|
||||
Creates a clan flake with the given name.
|
||||
@ -80,6 +81,12 @@ def generate_flake(
|
||||
shutil.copytree(flake_template, flake)
|
||||
sp.run(["chmod", "+w", "-R", str(flake)], check=True)
|
||||
|
||||
# initialize inventory
|
||||
if inventory:
|
||||
# check if inventory valid
|
||||
inventory_path = flake / "inventory.json"
|
||||
inventory_path.write_text(json.dumps(inventory, indent=2))
|
||||
|
||||
# substitute `substitutions` in all files of the template
|
||||
for file in flake.rglob("*"):
|
||||
if file.is_file():
|
||||
|
@ -14,6 +14,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../lib/build-clan"
|
||||
},
|
||||
{
|
||||
"path": "../../../democlan"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
Before Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 98 KiB |
Before Width: | Height: | Size: 155 KiB |
Before Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 163 KiB |
Before Width: | Height: | Size: 183 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 375 B |
After Width: | Height: | Size: 717 B |
After Width: | Height: | Size: 717 B |
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 152 KiB |
@ -158,9 +158,15 @@ class VMObject(GObject.Object):
|
||||
flake=uri.flake,
|
||||
)
|
||||
assert self.machine is not None
|
||||
state_dir = vm_state_dir(
|
||||
flake_url=str(self.machine.flake.url), vm_name=self.machine.name
|
||||
)
|
||||
|
||||
if self.machine.flake.is_local():
|
||||
state_dir = vm_state_dir(
|
||||
flake_url=str(self.machine.flake.path), vm_name=self.machine.name
|
||||
)
|
||||
else:
|
||||
state_dir = vm_state_dir(
|
||||
flake_url=self.machine.flake.url, vm_name=self.machine.name
|
||||
)
|
||||
self.qmp_wrap = QMPWrapper(state_dir)
|
||||
assert self.machine is not None
|
||||
yield self.machine
|
||||
|
@ -1,156 +0,0 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
from clan_cli.api import API
|
||||
|
||||
gi.require_version("WebKit", "6.0")
|
||||
|
||||
from gi.repository import GLib, WebKit
|
||||
|
||||
site_index: Path = (
|
||||
Path(sys.argv[0]).absolute()
|
||||
/ Path("../..")
|
||||
/ Path("clan_vm_manager/.webui/index.html")
|
||||
).resolve()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dataclass_to_dict(obj: Any) -> Any:
|
||||
"""
|
||||
Utility function to convert dataclasses to dictionaries
|
||||
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
|
||||
|
||||
It does NOT convert member functions.
|
||||
"""
|
||||
if dataclasses.is_dataclass(obj):
|
||||
return {k: dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()}
|
||||
elif isinstance(obj, list | tuple):
|
||||
return [dataclass_to_dict(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
return {k: dataclass_to_dict(v) for k, v in obj.items()}
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
class WebView:
|
||||
def __init__(self, methods: dict[str, Callable]) -> None:
|
||||
self.method_registry: dict[str, Callable] = methods
|
||||
|
||||
self.webview = WebKit.WebView()
|
||||
|
||||
settings = self.webview.get_settings()
|
||||
# settings.
|
||||
settings.set_property("enable-developer-extras", True)
|
||||
self.webview.set_settings(settings)
|
||||
|
||||
self.manager = self.webview.get_user_content_manager()
|
||||
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...")
|
||||
# Important: it seems postMessage must be given some payload, otherwise it won't trigger the event
|
||||
self.manager.register_script_message_handler("gtk")
|
||||
self.manager.connect("script-message-received", self.on_message_received)
|
||||
|
||||
self.webview.load_uri(f"file://{site_index}")
|
||||
|
||||
# global mutex lock to ensure functions run sequentially
|
||||
self.mutex_lock = Lock()
|
||||
self.queue_size = 0
|
||||
|
||||
def on_message_received(
|
||||
self, user_content_manager: WebKit.UserContentManager, message: Any
|
||||
) -> None:
|
||||
payload = json.loads(message.to_json(0))
|
||||
method_name = payload["method"]
|
||||
handler_fn = self.method_registry[method_name]
|
||||
|
||||
log.debug(f"Received message: {payload}")
|
||||
log.debug(f"Queue size: {self.queue_size} (Wait)")
|
||||
|
||||
def threaded_wrapper() -> bool:
|
||||
"""
|
||||
Ensures only one function is executed at a time
|
||||
|
||||
Wait until there is no other function acquiring the global lock.
|
||||
|
||||
Starts a thread with the potentially long running API function within.
|
||||
"""
|
||||
if not self.mutex_lock.locked():
|
||||
thread = threading.Thread(
|
||||
target=self.threaded_handler,
|
||||
args=(
|
||||
handler_fn,
|
||||
payload.get("data"),
|
||||
method_name,
|
||||
),
|
||||
)
|
||||
thread.start()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
GLib.idle_add(
|
||||
threaded_wrapper,
|
||||
)
|
||||
self.queue_size += 1
|
||||
|
||||
def threaded_handler(
|
||||
self,
|
||||
handler_fn: Callable[
|
||||
...,
|
||||
Any,
|
||||
],
|
||||
data: dict[str, Any] | None,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
with self.mutex_lock:
|
||||
log.debug("Executing... ", method_name)
|
||||
log.debug(f"{data}")
|
||||
if data is None:
|
||||
result = handler_fn()
|
||||
else:
|
||||
reconciled_arguments = {}
|
||||
for k, v in data.items():
|
||||
# Some functions expect to be called with dataclass instances
|
||||
# But the js api returns dictionaries.
|
||||
# Introspect the function and create the expected dataclass from dict dynamically
|
||||
# Depending on the introspected argument_type
|
||||
arg_type = API.get_method_argtype(method_name, k)
|
||||
if dataclasses.is_dataclass(arg_type):
|
||||
reconciled_arguments[k] = arg_type(**v)
|
||||
else:
|
||||
reconciled_arguments[k] = v
|
||||
|
||||
result = handler_fn(**reconciled_arguments)
|
||||
|
||||
serialized = json.dumps(dataclass_to_dict(result))
|
||||
|
||||
# Use idle_add to queue the response call to js on the main GTK thread
|
||||
GLib.idle_add(self.return_data_to_js, method_name, serialized)
|
||||
self.queue_size -= 1
|
||||
log.debug(f"Done: Remaining queue size: {self.queue_size}")
|
||||
|
||||
def return_data_to_js(self, method_name: str, serialized: str) -> bool:
|
||||
# This function must be run on the main GTK thread to interact with the webview
|
||||
# result = method_fn(data) # takes very long
|
||||
# serialized = result
|
||||
self.webview.evaluate_javascript(
|
||||
f"""
|
||||
window.clan.{method_name}(`{serialized}`);
|
||||
""",
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def get_webview(self) -> WebKit.WebView:
|
||||
return self.webview
|
@ -2,7 +2,6 @@ import logging
|
||||
import threading
|
||||
|
||||
import gi
|
||||
from clan_cli.api import API
|
||||
from clan_cli.history.list import list_history
|
||||
|
||||
from clan_vm_manager.components.interfaces import ClanConfig
|
||||
@ -12,7 +11,6 @@ from clan_vm_manager.singletons.use_vms import ClanStore
|
||||
from clan_vm_manager.views.details import Details
|
||||
from clan_vm_manager.views.list import ClanList
|
||||
from clan_vm_manager.views.logs import Logs
|
||||
from clan_vm_manager.views.webview import WebView
|
||||
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
@ -61,9 +59,6 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
stack_view.add_named(Details(), "details")
|
||||
stack_view.add_named(Logs(), "logs")
|
||||
|
||||
webview = WebView(methods=API._registry)
|
||||
stack_view.add_named(webview.get_webview(), "webview")
|
||||
|
||||
stack_view.set_visible_child_name(config.initial_view)
|
||||
|
||||
view.set_content(scroll)
|
||||
|
@ -18,7 +18,6 @@
|
||||
runCommand,
|
||||
setuptools,
|
||||
webkitgtk_6_0,
|
||||
webview-ui,
|
||||
wrapGAppsHook,
|
||||
}:
|
||||
let
|
||||
@ -26,7 +25,7 @@ let
|
||||
desktop-file = makeDesktopItem {
|
||||
name = "org.clan.vm-manager";
|
||||
exec = "clan-vm-manager %u";
|
||||
icon = ./clan_vm_manager/assets/clan_white.png;
|
||||
icon = "clan-white";
|
||||
desktopName = "Clan Manager";
|
||||
startupWMClass = "clan";
|
||||
mimeTypes = [ "x-scheme-handler/clan" ];
|
||||
@ -142,10 +141,9 @@ python3.pkgs.buildPythonApplication rec {
|
||||
passthru.runtimeDependencies = runtimeDependencies;
|
||||
passthru.testDependencies = testDependencies;
|
||||
|
||||
# TODO: place webui in lib/python3.11/site-packages/clan_vm_manager
|
||||
postInstall = ''
|
||||
mkdir -p $out/clan_vm_manager/.webui
|
||||
cp -r ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* $out/clan_vm_manager/.webui
|
||||
mkdir -p $out/share/icons/hicolor
|
||||
cp -r ./clan_vm_manager/assets/white-favicons/* $out/share/icons/hicolor
|
||||
'';
|
||||
|
||||
# Don't leak python packages into a devshell.
|
||||
|
@ -13,10 +13,10 @@
|
||||
else
|
||||
{
|
||||
devShells.clan-vm-manager = pkgs.callPackage ./shell.nix {
|
||||
inherit (config.packages) clan-vm-manager webview-ui;
|
||||
inherit (config.packages) clan-vm-manager;
|
||||
};
|
||||
packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix {
|
||||
inherit (config.packages) clan-cli webview-ui;
|
||||
inherit (config.packages) clan-cli;
|
||||
};
|
||||
|
||||
checks = config.packages.clan-vm-manager.tests;
|
||||
|
@ -1,23 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CLAN=$(nix build .#clan-vm-manager --print-out-paths)
|
||||
|
||||
if ! command -v xdg-mime &> /dev/null; then
|
||||
echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed."
|
||||
fi
|
||||
|
||||
ALREADY_INSTALLED=$(nix profile list --json | jq 'has("elements") and (.elements | has("clan-vm-manager"))')
|
||||
|
||||
if [ "$ALREADY_INSTALLED" = "true" ]; then
|
||||
echo "Upgrading installed clan-vm-manager"
|
||||
nix profile upgrade clan-vm-manager
|
||||
else
|
||||
nix profile install .#clan-vm-manager --priority 4
|
||||
fi
|
||||
|
||||
|
||||
# install desktop file
|
||||
set -eou pipefail
|
||||
DESKTOP_FILE_NAME=org.clan.vm-manager.desktop
|
||||
DESKTOP_DST=~/.local/share/applications/"$DESKTOP_FILE_NAME"
|
||||
DESKTOP_SRC="$CLAN/share/applications/$DESKTOP_FILE_NAME"
|
||||
UI_BIN="$CLAN/bin/clan-vm-manager"
|
||||
|
||||
cp -f "$DESKTOP_SRC" "$DESKTOP_DST"
|
||||
sleep 2
|
||||
sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" "$DESKTOP_DST"
|
||||
xdg-mime default "$DESKTOP_FILE_NAME" x-scheme-handler/clan
|
||||
echo "==== Validating desktop file installation ===="
|
||||
set -x
|
||||
desktop-file-validate "$DESKTOP_DST"
|
||||
set +xeou pipefail
|
||||
|
@ -10,7 +10,7 @@
|
||||
python3,
|
||||
gtk4,
|
||||
libadwaita,
|
||||
webview-ui,
|
||||
|
||||
}:
|
||||
|
||||
let
|
||||
@ -52,11 +52,5 @@ mkShell {
|
||||
|
||||
# Add clan-cli to the python path so that we can import it without building it in nix first
|
||||
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
|
||||
|
||||
# Add the webview-ui to the .webui directory
|
||||
rm -rf ./clan_vm_manager/.webui/*
|
||||
mkdir -p ./clan_vm_manager/.webui
|
||||
cp -a ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* ./clan_vm_manager/.webui
|
||||
chmod -R +w ./clan_vm_manager/.webui
|
||||
'';
|
||||
}
|
||||
|