#!/usr/bin/env python3 # ───────────────────────────────────────────────────────────────────────────── # Arcline OS — graphical installer (runs in the live session) # # A small GTK3 frontend for scripts/deploy-disk.sh. It boots to the console # of the live ISO and walks through: pick a target disk -> confirm (type the # device path, like scripts/install.sh) -> install, streaming the deploy log # into the window. Everything destructive is delegated to deploy-disk.sh, # which is shipped in the live image at /usr/lib/arcline. # # Requires root (the live session runs as root). No network needed. # ───────────────────────────────────────────────────────────────────────────── import glob import json import os import re import shutil import subprocess import sys import tempfile import gi gi.require_version("Gtk", "3.0") from gi.repository import GLib, Gtk DEPLOY = "/usr/lib/arcline/scripts/deploy-disk.sh" ARCHIVE_CANDIDATES = ( "/install/*.tar.xz", "/run/live/medium/install/*.tar.xz", "/lib/live/mount/medium/install/*.tar.xz", "/media/*/install/*.tar.xz", "/mnt/*/install/*.tar.xz", ) def find_archive(): for pattern in ARCHIVE_CANDIDATES: hits = sorted(glob.glob(pattern)) if hits: return hits[0] return None def current_edition(): try: with open("/etc/arcline-release", encoding="utf-8") as fh: for line in fh: m = re.match(r"Edition:\s*(\S+)", line) if m: return m.group(1) except OSError: pass return "server" def list_disks(): try: out = subprocess.run( ["lsblk", "-J", "-o", "NAME,SIZE,TYPE,MODEL,RM,RO"], capture_output=True, text=True, check=True, timeout=10, ).stdout data = json.loads(out) except (subprocess.SubprocessError, json.JSONDecodeError): return [] disks = [] for d in data.get("blockdevices", []): if d.get("type") != "disk" or d.get("ro"): continue name = d.get("name", "") # hide pseudo-devices that are never install targets if name.startswith(("zram", "loop", "ram")): continue disks.append(d) return disks class Installer(Gtk.Window): def __init__(self): super().__init__(title="Arcline OS Installer") self.set_default_size(760, 640) self.proc = None self.tmpdir = None self.install_ok = False self.archive = find_archive() self.edition = current_edition() box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_top(16) box.set_margin_bottom(16) box.set_margin_start(20) box.set_margin_end(20) self.add(box) title = Gtk.Label() title.set_markup(f"Arcline OS Installer — {self.edition} edition") box.pack_start(title, False, False, 0) # install source if self.archive: src = Gtk.Label() src.set_halign(Gtk.Align.START) src.set_markup(f"Installing from: {self.archive}") box.pack_start(src, False, False, 0) else: warn = Gtk.Label(label="WARNING: no install archive found (expected /install/*.tar.xz on the ISO)") warn.set_name("warn") box.pack_start(warn, False, False, 0) # target disk self.disk_combo = Gtk.ComboBoxText() self.disks = list_disks() for d in self.disks: model = (d.get("model") or "").strip() removable = "(removable)" if d.get("rm") else "" self.disk_combo.append_text(f"/dev/{d['name']} {d.get('size','?')} {model} {removable}".strip()) if not self.disks: self.disk_combo.append_text("(no writable disks found)") self.disk_combo.set_active(0) row_disk = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row_disk.pack_start(Gtk.Label(label="Target disk:"), False, False, 0) row_disk.pack_start(self.disk_combo, True, True, 0) box.pack_start(row_disk, False, False, 0) # boot mode self.boot_combo = Gtk.ComboBoxText() for label, val in (("BIOS (grub-pc)", "bios"), ("UEFI (grub-efi-amd64)", "efi")): self.boot_combo.append(label, val) self.boot_combo.set_active(0) row_boot = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row_boot.pack_start(Gtk.Label(label="Boot mode:"), False, False, 0) row_boot.pack_start(self.boot_combo, True, True, 0) box.pack_start(row_boot, False, False, 0) # danger + confirm danger = Gtk.Label() danger.set_name("warn") danger.set_markup("⚠ ALL DATA on the target disk will be destroyed.") box.pack_start(danger, False, False, 0) row_confirm = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row_confirm.pack_start(Gtk.Label(label="Type the device path to confirm:"), False, False, 0) self.confirm = Gtk.Entry() self.confirm.set_placeholder_text("/dev/sda") row_confirm.pack_start(self.confirm, True, True, 0) box.pack_start(row_confirm, False, False, 0) # install button self.install_btn = Gtk.Button(label="Install Arcline OS") self.install_btn.set_sensitive(self.archive is not None and bool(self.disks)) self.install_btn.connect("clicked", self.on_install) box.pack_start(self.install_btn, False, False, 0) # output self.output = Gtk.TextView() self.output.set_editable(False) self.output.set_monospace(True) scroller = Gtk.ScrolledWindow() scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroller.set_vexpand(True) scroller.add(self.output) box.pack_start(scroller, True, True, 0) # status + reboot self.status = Gtk.Label(label="") box.pack_start(self.status, False, False, 0) self.reboot_btn = Gtk.Button(label="Reboot") self.reboot_btn.set_sensitive(False) self.reboot_btn.connect("clicked", lambda *_: subprocess.Popen(["systemctl", "reboot"])) box.pack_start(self.reboot_btn, False, False, 0) self.connect("destroy", Gtk.main_quit) # ── helpers ───────────────────────────────────────────────────────────── def append(self, text): buf = self.output.get_buffer() buf.insert(buf.get_end_iter(), text) def selected_device(self): i = self.disk_combo.get_active() if i < 0 or i >= len(self.disks): return None return f"/dev/{self.disks[i]['name']}" def boot_mode(self): return self.boot_combo.get_active_id() or "bios" # ── install ───────────────────────────────────────────────────────────── def on_install(self, _btn): dev = self.selected_device() if not dev: self.status.set_text("Select a target disk first.") return if self.confirm.get_text().strip() != dev: self.status.set_markup(f"Type {dev} exactly to confirm.") return self.install_btn.set_sensitive(False) self.status.set_text("Preparing…") self.tmpdir = tempfile.mkdtemp(prefix="arcline-install-") try: shutil.unpack_archive(self.archive, self.tmpdir, format="xztar") except Exception as exc: # noqa: BLE001 self.status.set_markup(f"Failed to extract archive: {exc}") self.install_btn.set_sensitive(True) return cmd = [DEPLOY, dev, self.tmpdir, self.edition, "--boot", self.boot_mode()] self.append(f"$ {' '.join(cmd)}\n") self.proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) self.status.set_text("Installing… this takes a while.") GLib.timeout_add(100, self.drain) def drain(self): if self.proc is None: return False line = self.proc.stdout.readline() if line: self.append(line) # keep the tail visible adj = self.output.get_parent().get_vadjustment() adj.set_value(adj.get_upper()) return True rc = self.proc.poll() if rc is None: return True self.proc.stdout.close() self.proc = None if rc == 0: self.install_ok = True self.status.set_markup("Install complete. Remove the media and reboot.") self.reboot_btn.set_sensitive(True) else: self.status.set_markup(f"Install failed (exit {rc}). See log above.") self.install_btn.set_sensitive(True) if self.tmpdir: shutil.rmtree(self.tmpdir, ignore_errors=True) self.tmpdir = None return False def main(): if os.geteuid() != 0: print("error: the Arcline installer must run as root", file=sys.stderr) return 1 if not os.path.exists(DEPLOY): print(f"error: {DEPLOY} not found — installer is incomplete", file=sys.stderr) return 1 win = Installer() win.show_all() Gtk.main() return 0 if __name__ == "__main__": sys.exit(main())