diff --git a/pkgs/clan-cli/clan_cli/cmd.py b/pkgs/clan-cli/clan_cli/cmd.py index cc0fc7c15..53d0634c4 100644 --- a/pkgs/clan-cli/clan_cli/cmd.py +++ b/pkgs/clan-cli/clan_cli/cmd.py @@ -4,6 +4,7 @@ import math import os import select import shlex +import shutil import signal import subprocess import threading @@ -271,6 +272,38 @@ class RunOpts: needs_user_terminal: bool = False timeout: float = math.inf shell: bool = False + # Some commands require sudo + requires_root_perm: bool = False + # Ask for sudo password in a graphical way. + # This is needed for GUI applications + graphical_perm: bool = False + + +def cmd_with_root(cmd: list[str], graphical: bool = False) -> list[str]: + """ + This function returns a wrapped command that will be run with root permissions. + It will use sudo if graphical is False, otherwise it will use run0 or pkexec. + """ + if os.geteuid() == 0: + return cmd + + # Decide permission handler + if graphical: + # TODO(mic92): figure out how to use run0 + # if shutil.which("run0") is not None: + # perm_prefix = "run0" + if shutil.which("pkexec") is not None: + return ["pkexec", *cmd] + description = ( + "pkexec is required to launch root commands with graphical permissions" + ) + msg = "Missing graphical permission handler" + raise ClanError(msg, description=description) + if shutil.which("sudo") is None: + msg = "sudo is required to run this command as a non-root user" + raise ClanError(msg) + + return ["sudo", *cmd] def run( @@ -293,6 +326,9 @@ def run( if options.stderr is None: options.stderr = async_ctx.stderr + if options.requires_root_perm: + cmd = cmd_with_root(cmd, options.graphical_perm) + if options.input: if any(not ch.isprintable() for ch in options.input.decode("ascii", "replace")): filtered_input = "<>" diff --git a/pkgs/clan-cli/clan_cli/flash/automount.py b/pkgs/clan-cli/clan_cli/flash/automount.py index af2295f0d..a60f757ed 100644 --- a/pkgs/clan-cli/clan_cli/flash/automount.py +++ b/pkgs/clan-cli/clan_cli/flash/automount.py @@ -13,7 +13,7 @@ log = logging.getLogger(__name__) @contextmanager def pause_automounting( - devices: list[Path], machine: Machine + devices: list[Path], machine: Machine, request_graphical: bool = False ) -> Generator[None, None, None]: """ Pause automounting on the device for the duration of this context @@ -32,17 +32,32 @@ def pause_automounting( raise ClanError(msg) str_devs = [str(dev) for dev in devices] - cmd = ["sudo", str(inhibit_path), "enable", *str_devs] + cmd = [str(inhibit_path), "enable", *str_devs] + result = run( cmd, RunOpts( - log=Log.BOTH, check=False, needs_user_terminal=True, prefix=machine.name + log=Log.BOTH, + check=False, + needs_user_terminal=True, + prefix=machine.name, + requires_root_perm=True, + graphical_perm=request_graphical, ), ) if result.returncode != 0: machine.error("Failed to inhibit automounting") yield None - cmd = ["sudo", str(inhibit_path), "disable", *str_devs] - result = run(cmd, RunOpts(log=Log.BOTH, check=False, prefix=machine.name)) + cmd = [str(inhibit_path), "disable", *str_devs] + result = run( + cmd, + RunOpts( + log=Log.BOTH, + check=False, + prefix=machine.name, + requires_root_perm=True, + graphical_perm=request_graphical, + ), + ) if result.returncode != 0: machine.error("Failed to re-enable automounting") diff --git a/pkgs/clan-cli/clan_cli/flash/flash.py b/pkgs/clan-cli/clan_cli/flash/flash.py index 8b0002372..0ae009a1d 100644 --- a/pkgs/clan-cli/clan_cli/flash/flash.py +++ b/pkgs/clan-cli/clan_cli/flash/flash.py @@ -2,14 +2,13 @@ import importlib import json import logging import os -import shutil from dataclasses import dataclass, field from pathlib import Path from tempfile import TemporaryDirectory from typing import Any from clan_cli.api import API -from clan_cli.cmd import Log, RunOpts, run +from clan_cli.cmd import Log, RunOpts, cmd_with_root, run from clan_cli.errors import ClanError from clan_cli.facts.generate import generate_facts from clan_cli.facts.secret_modules import SecretStoreBase @@ -47,9 +46,10 @@ def flash_machine( write_efi_boot_entries: bool, debug: bool, extra_args: list[str] | None = None, + graphical: bool = False, ) -> None: devices = [Path(disk.device) for disk in disks] - with pause_automounting(devices, machine): + with pause_automounting(devices, machine, request_graphical=graphical): if extra_args is None: extra_args = [] system_config_nix: dict[str, Any] = {} @@ -108,10 +108,13 @@ def flash_machine( disko_install = [] if os.geteuid() != 0: - if shutil.which("sudo") is None: - msg = "sudo is required to run disko-install as a non-root user" - raise ClanError(msg) - wrapper = 'set -x; disko_install=$(command -v disko-install); exec sudo "$disko_install" "$@"' + wrapper = " ".join( + [ + "disko_install=$(command -v disko-install);", + "exec", + *cmd_with_root(['"$disko_install" "$@"'], graphical=graphical), + ] + ) disko_install.extend(["bash", "-c", wrapper]) disko_install.append("disko-install") @@ -124,6 +127,8 @@ def flash_machine( for disk in disks: disko_install.extend(["--disk", disk.name, disk.device]) + log.info("Will flash disk %s: %s", disk.name, disk.device) + disko_install.extend(["--extra-files", str(local_dir), upload_dir]) disko_install.extend(["--flake", str(machine.flake) + "#" + machine.name]) disko_install.extend(["--mode", str(mode)]) diff --git a/pkgs/webview-ui/app/src/components/modal/index.tsx b/pkgs/webview-ui/app/src/components/modal/index.tsx index 3542c28fe..c6869fe67 100644 --- a/pkgs/webview-ui/app/src/components/modal/index.tsx +++ b/pkgs/webview-ui/app/src/components/modal/index.tsx @@ -1,5 +1,5 @@ import Dialog from "corvu/dialog"; -import { createEffect, createSignal, JSX } from "solid-js"; +import { createSignal, JSX } from "solid-js"; import { Button } from "../button"; import Icon from "../icon"; import cx from "classnames"; @@ -13,7 +13,7 @@ interface ModalProps { export const Modal = (props: ModalProps) => { const [dragging, setDragging] = createSignal(false); const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 }); - // const [dialogStyle, setDialogStyle] = createSignal({ top: 100, left: 100 }); + let dialogRef: HTMLDivElement; const handleMouseDown = (e: MouseEvent) => { @@ -37,9 +37,6 @@ export const Modal = (props: ModalProps) => { const handleMouseUp = () => setDragging(false); - createEffect(() => { - console.log("dialog open", props.open); - }); return ( diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index 257953729..9572841c9 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -14,6 +14,7 @@ import { FieldValues, setValue, getValue, + getValues, } from "@modular-forms/solid"; import { createQuery } from "@tanstack/solid-query"; import { createEffect, createSignal, For, Show } from "solid-js"; @@ -146,63 +147,89 @@ export const Flash = () => { return dataTransfer.files; }; const [confirmOpen, setConfirmOpen] = createSignal(false); + const [isFlashing, setFlashing] = createSignal(false); - const handleConfirm = async (values: FlashFormValues) => { + const handleSubmit = (values: FlashFormValues) => { setConfirmOpen(true); - console.log("Submit:", values); - toast.error("Not fully implemented yet"); - // Disabled for now. To prevent accidental flashing of local disks - // User should confirm the disk to flash to - - // try { - // await callApi("flash_machine", { - // machine: { - // name: values.machine.devicePath, - // flake: { - // loc: values.machine.flake, - // }, - // }, - // mode: "format", - // disks: [{ name: "main", device: values.disk }], - // system_config: { - // language: values.language, - // keymap: values.keymap, - // ssh_keys_path: values.sshKeys.map((file) => file.name), - // }, - // dry_run: false, - // write_efi_boot_entries: false, - // debug: false, - // }); - // } catch (error) { - // toast.error(`Error could not flash disk: ${error}`); - // console.error("Error submitting form:", error); - // } + }; + const handleConfirm = async () => { + // Wait for the flash to complete + const values = getValues(formStore) as FlashFormValues; + setFlashing(true); + console.log("Confirmed flash:", values); + try { + await toast.promise( + callApi("flash_machine", { + machine: { + name: values.machine.devicePath, + flake: { + loc: values.machine.flake, + }, + }, + mode: "format", + disks: [{ name: "main", device: values.disk }], + system_config: { + language: values.language, + keymap: values.keymap, + ssh_keys_path: values.sshKeys.map((file) => file.name), + }, + dry_run: false, + write_efi_boot_entries: false, + debug: false, + graphical: true, + }), + { + error: (errors) => `Error flashing disk: ${errors}`, + loading: "Flashing ... This may take up to 15minutes.", + success: "Disk flashed successfully", + }, + ); + } catch (error) { + toast.error(`Error could not flash disk: ${error}`); + } finally { + setFlashing(false); + } + setConfirmOpen(false); }; return ( <>
setConfirmOpen(false)} + open={confirmOpen() || isFlashing()} + handleClose={() => !isFlashing() && setConfirmOpen(false)} title="Confirm" >
-
+
- Warning: All data on will be lost. -
+ Warning: All data will be lost. +
+ Selected disk: '{getValue(formStore, "disk")}'
- - + +
@@ -214,7 +241,7 @@ export const Flash = () => { Will make bootstrapping new machines easier by providing secure remote connection to any machine when plugged in. -
+
{(field, props) => ( @@ -259,6 +286,7 @@ export const Flash = () => { labelProps={{ labelAction: (