#!/usr/bin/env python3 import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Pango, GdkPixbuf import subprocess import threading import os import sys LOGO = "/etc/sovran/logo.png" LOG = "/tmp/sovran-install.log" FLAKE = "/etc/sovran/flake" logfile = open(LOG, "a") def log(msg): logfile.write(msg + "\n") logfile.flush() def run(cmd, **kwargs): log(f"$ {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) log(result.stdout) if result.returncode != 0: log(result.stderr) raise RuntimeError(result.stderr or f"Command failed: {cmd}") return result.stdout.strip() def run_stream(cmd, text_buffer): log(f"$ {' '.join(cmd)}") proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout: log(line.rstrip()) GLib.idle_add(append_text, text_buffer, line) proc.wait() if proc.returncode != 0: raise RuntimeError(f"Command failed with code {proc.returncode}. See {LOG}") def append_text(buf, text): buf.insert(buf.get_end_iter(), text) return False def human_size(nbytes): for unit in ["B","KB","MB","GB","TB"]: if nbytes < 1024: return f"{nbytes:.1f} {unit}" nbytes /= 1024 return f"{nbytes:.1f} PB" # ── Window base ──────────────────────────────────────────────────────────────── class InstallerWindow(Gtk.Window): def __init__(self): super().__init__(title="Sovran_SystemsOS Installer") self.set_default_size(800, 560) self.set_position(Gtk.WindowPosition.CENTER) self.set_resizable(False) if os.path.exists(LOGO): self.set_icon_from_file(LOGO) self.connect("delete-event", Gtk.main_quit) self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) self.stack.set_transition_duration(300) self.add(self.stack) self.role = None self.boot_disk = None self.boot_size = None self.data_disk = None self.data_size = None self.show_welcome() self.show_all() # ── Helpers ──────────────────────────────────────────────────────────── def clear_stack(self): for child in self.stack.get_children(): self.stack.remove(child) def set_page(self, widget, name="page"): self.clear_stack() self.stack.add_named(widget, name) self.stack.set_visible_child_name(name) self.show_all() def make_header(self, title, subtitle=None): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) box.set_margin_top(32) box.set_margin_bottom(16) box.set_margin_start(40) box.set_margin_end(40) lbl = Gtk.Label() lbl.set_markup(f"{title}") lbl.set_halign(Gtk.Align.START) box.pack_start(lbl, False, False, 0) if subtitle: sub = Gtk.Label() sub.set_markup(f"{subtitle}") sub.set_halign(Gtk.Align.START) box.pack_start(sub, False, False, 0) sep = Gtk.Separator() sep.set_margin_top(12) box.pack_start(sep, False, False, 0) return box def make_nav(self, back_label=None, back_cb=None, next_label="Continue", next_cb=None): bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) bar.set_margin_top(8) bar.set_margin_bottom(20) bar.set_margin_start(40) bar.set_margin_end(40) if back_label and back_cb: btn = Gtk.Button(label=back_label) btn.connect("clicked", back_cb) bar.pack_start(btn, False, False, 0) bar.pack_start(Gtk.Label(), True, True, 0) if next_cb: btn = Gtk.Button(label=next_label) btn.get_style_context().add_class("suggested-action") btn.connect("clicked", next_cb) bar.pack_end(btn, False, False, 0) return bar def error(self, msg): GLib.idle_add(self._show_error, msg) def _show_error(self, msg): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.pack_start(self.make_header("Installation Error", "Something went wrong."), False, False, 0) body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) body.set_margin_start(40) body.set_margin_end(40) icon = Gtk.Label() icon.set_markup("") body.pack_start(icon, False, False, 0) lbl = Gtk.Label(label=msg) lbl.set_line_wrap(True) lbl.set_max_width_chars(60) lbl.set_justify(Gtk.Justification.CENTER) body.pack_start(lbl, False, False, 0) log_lbl = Gtk.Label() log_lbl.set_markup(f"Full log: {LOG}") body.pack_start(log_lbl, False, False, 0) page.pack_start(body, True, True, 0) quit_btn = Gtk.Button(label="Close Installer") quit_btn.connect("clicked", Gtk.main_quit) nav = Gtk.Box() nav.set_margin_bottom(20) nav.set_margin_end(40) nav.pack_end(quit_btn, False, False, 0) page.pack_start(nav, False, False, 0) self.set_page(page) return False # ── Step 1: Welcome & Role ───────────────────────────────────────────── def show_welcome(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) # Hero banner hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) hero.set_margin_top(36) hero.set_margin_bottom(20) if os.path.exists(LOGO): try: pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(LOGO, 80, 80, True) img = Gtk.Image.new_from_pixbuf(pb) hero.pack_start(img, False, False, 0) except Exception: pass title = Gtk.Label() title.set_markup("Sovran Systems") hero.pack_start(title, False, False, 0) sub = Gtk.Label() sub.set_markup("Be Digitally Sovereign") hero.pack_start(sub, False, False, 0) page.pack_start(hero, False, False, 0) sep = Gtk.Separator() sep.set_margin_start(40) sep.set_margin_end(40) page.pack_start(sep, False, False, 0) # Role selection lbl = Gtk.Label() lbl.set_markup("Select your installation type:") lbl.set_margin_top(20) lbl.set_margin_start(40) lbl.set_halign(Gtk.Align.START) page.pack_start(lbl, False, False, 0) roles = [ ("Server + Desktop", "The full Sovran experience: beautiful desktop + your own cloud, secure messaging, Bitcoin node, and more.", "Server+Desktop"), ("Desktop Only", "A beautiful, easy-to-use desktop without the background server applications.", "Desktop Only"), ("Node Only", "Full Bitcoin node with Lightning and non-KYC buying and selling. No desktop.", "Node (Bitcoin-only)"), ] radio_group = None self._role_radios = [] role_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) role_box.set_margin_start(40) role_box.set_margin_end(40) role_box.set_margin_top(8) for label, desc, key in roles: row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) row.set_margin_bottom(8) radio = Gtk.RadioButton(group=radio_group, label=label) radio.set_name(key) if radio_group is None: radio_group = radio radio.set_active(True) radio.get_style_context().add_class("role-radio") self._role_radios.append(radio) desc_lbl = Gtk.Label(label=desc) desc_lbl.set_line_wrap(True) desc_lbl.set_max_width_chars(70) desc_lbl.set_halign(Gtk.Align.START) desc_lbl.set_margin_start(28) desc_lbl.get_style_context().add_class("dim-label") row.pack_start(radio, False, False, 0) row.pack_start(desc_lbl, False, False, 0) role_box.pack_start(row, False, False, 0) page.pack_start(role_box, False, False, 0) page.pack_start(Gtk.Label(), True, True, 0) page.pack_start(self.make_nav(next_label="Next →", next_cb=self.on_role_next), False, False, 0) self.set_page(page, "welcome") def on_role_next(self, btn): for radio in self._role_radios: if radio.get_active(): self.role = radio.get_name() break self.show_disk_confirm() # ── Step 2: Disk Confirm ─────────────────────────────────────────────── def show_disk_confirm(self): # Detect disks try: raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"]) except Exception as e: self.error(str(e)) return disks = [] for line in raw.splitlines(): parts = line.split() if len(parts) >= 4 and parts[2] == "disk" and parts[3] == "0": tran = parts[4] if len(parts) >= 5 else "" if tran != "usb": disks.append((parts[0], int(parts[1]))) if not disks: self.error("No valid internal drives found. USB drives are excluded.") return disks.sort(key=lambda x: x[1]) self.boot_disk, self.boot_size = disks[0] self.data_disk, self.data_size = None, None BYTES_2TB = 2 * 1024 ** 4 if len(disks) >= 2: d, s = disks[-1] if s >= BYTES_2TB: self.data_disk, self.data_size = d, s page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.pack_start(self.make_header( "Confirm Installation", "Review the disks below. ALL DATA will be permanently erased." ), False, False, 0) body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) body.set_margin_start(40) body.set_margin_end(40) body.set_margin_top(8) # Disk info box disk_frame = Gtk.Frame() disk_frame.set_shadow_type(Gtk.ShadowType.IN) disk_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) disk_inner.set_margin_top(12) disk_inner.set_margin_bottom(12) disk_inner.set_margin_start(16) disk_inner.set_margin_end(16) boot_lbl = Gtk.Label() boot_lbl.set_markup(f"Boot disk: /dev/{self.boot_disk} ({human_size(self.boot_size)})") boot_lbl.set_halign(Gtk.Align.START) disk_inner.pack_start(boot_lbl, False, False, 0) if self.data_disk: data_lbl = Gtk.Label() data_lbl.set_markup(f"Data disk: /dev/{self.data_disk} ({human_size(self.data_size)})") data_lbl.set_halign(Gtk.Align.START) disk_inner.pack_start(data_lbl, False, False, 0) else: no_data = Gtk.Label() no_data.set_markup("Data disk: none detected (requires 2TB+)") no_data.set_halign(Gtk.Align.START) disk_inner.pack_start(no_data, False, False, 0) disk_frame.add(disk_inner) body.pack_start(disk_frame, False, False, 0) warn = Gtk.Label() warn.set_markup( "⚠ This action cannot be undone. " "All existing data on the above disk(s) will be permanently destroyed." ) warn.set_line_wrap(True) warn.set_max_width_chars(65) warn.set_halign(Gtk.Align.START) body.pack_start(warn, False, False, 0) # Confirm entry confirm_lbl = Gtk.Label(label='Type ERASE to confirm:') confirm_lbl.set_halign(Gtk.Align.START) body.pack_start(confirm_lbl, False, False, 0) self._confirm_entry = Gtk.Entry() self._confirm_entry.set_placeholder_text("ERASE") body.pack_start(self._confirm_entry, False, False, 0) page.pack_start(body, False, False, 0) page.pack_start(Gtk.Label(), True, True, 0) page.pack_start(self.make_nav( back_label="← Back", back_cb=lambda b: self.show_welcome(), next_label="Begin Installation", next_cb=self.on_confirm_next ), False, False, 0) self.set_page(page, "disks") def on_confirm_next(self, btn): if self._confirm_entry.get_text().strip() != "ERASE": dlg = Gtk.MessageDialog( transient_for=self, modal=True, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, text="You must type ERASE exactly to continue." ) dlg.run() dlg.destroy() return self.show_progress("Preparing Drives", "Partitioning and formatting your drives...", self.do_partition) # ── Step 3 & 5: Progress with live log ──────────────────────────────── def show_progress(self, title, subtitle, worker_fn): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.pack_start(self.make_header(title, subtitle), False, False, 0) body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) body.set_margin_start(40) body.set_margin_end(40) spinner = Gtk.Spinner() spinner.set_size_request(48, 48) spinner.start() body.pack_start(spinner, False, False, 0) # Scrollable live log sw = Gtk.ScrolledWindow() sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sw.set_size_request(-1, 260) tv = Gtk.TextView() tv.set_editable(False) tv.set_cursor_visible(False) tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) tv.modify_font(Pango.FontDescription("Monospace 9")) tv.get_style_context().add_class("log-view") buf = tv.get_buffer() sw.add(tv) body.pack_start(sw, True, True, 0) # Auto-scroll to bottom def on_changed(b): adj = sw.get_vadjustment() adj.set_value(adj.get_upper() - adj.get_page_size()) buf.connect("changed", on_changed) page.pack_start(body, True, True, 0) self.set_page(page, "progress") def thread_fn(): try: worker_fn(buf) except Exception as e: self.error(str(e)) t = threading.Thread(target=thread_fn, daemon=True) t.start() # ── Worker: partition ───────────────────────────────────────────────── def do_partition(self, buf): GLib.idle_add(append_text, buf, "=== Partitioning drives ===\n") boot_path = f"/dev/{self.boot_disk}" cmd = [ "sudo", "disko", "--mode", "disko", f"{FLAKE}/iso/disko.nix", "--arg", "device", f'"{boot_path}"' ] if self.data_disk: cmd += ["--arg", "dataDevice", f'"/dev/{self.data_disk}"'] run_stream(cmd, buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf) GLib.idle_add(append_text, buf, "\n=== Copying flake to /mnt ===\n") run(["sudo", "cp", "/mnt/etc/nixos/hardware-configuration.nix", "/tmp/hardware-configuration.nix"]) run(["sudo", "rm", "-rf", "/mnt/etc/nixos/"]) run(["sudo", "mkdir", "-p", "/mnt/etc/nixos"]) run(["sudo", "cp", "-a", f"{FLAKE}/.", "/mnt/etc/nixos/"]) run(["sudo", "cp", "/tmp/hardware-configuration.nix", "/mnt/etc/nixos/hardware-configuration.nix"]) GLib.idle_add(append_text, buf, "\n=== Writing role config ===\n") self.write_role_state() GLib.idle_add(self.show_install_step) def write_role_state(self): is_server = str(self.role == "Server+Desktop").lower() is_desktop = str(self.role == "Desktop Only").lower() is_node = str(self.role == "Node (Bitcoin-only)").lower() content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT. {{ config, lib, ... }}: {{ sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server}; sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop}; sovran_systemsOS.roles.node = lib.mkDefault {is_node}; }} """ proc = subprocess.run( ["sudo", "tee", "/mnt/etc/nixos/role-state.nix"], input=content, text=True, capture_output=True ) log(proc.stdout) if proc.returncode != 0: raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}") run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"]) # ── Step 4: Confirm before full install ─────────────────────────────── def show_install_step(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.pack_start(self.make_header( "Drives Ready", "Your drives have been partitioned. Ready to install Sovran SystemsOS." ), False, False, 0) body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) body.set_margin_start(40) body.set_margin_end(40) body.set_margin_top(16) icon = Gtk.Label() icon.set_markup("") body.pack_start(icon, False, False, 0) info = Gtk.Label() info.set_markup( f"Role: {self.role}\n" f"Boot disk: /dev/{self.boot_disk} ({human_size(self.boot_size)})\n" + (f"Data disk: /dev/{self.data_disk} ({human_size(self.data_size)})" if self.data_disk else "Data disk: none") ) info.set_halign(Gtk.Align.CENTER) info.set_justify(Gtk.Justification.CENTER) body.pack_start(info, False, False, 0) note = Gtk.Label() note.set_markup( "The next step will install the full system.\n" "This may take 20–40 minutes depending on your internet speed.\n" "Do not turn off your computer." ) note.set_justify(Gtk.Justification.CENTER) note.set_line_wrap(True) body.pack_start(note, False, False, 0) page.pack_start(body, True, True, 0) page.pack_start(self.make_nav( next_label="Install Now", next_cb=lambda b: self.show_progress( "Installing Sovran SystemsOS", "Building and installing your system. This will take a while...", self.do_install ) ), False, False, 0) self.set_page(page, "ready") return False # ── Worker: install ─────────────────────────────────────────────────── def do_install(self, buf): GLib.idle_add(append_text, buf, "=== Running nixos-install ===\n") run_stream([ "sudo", "nixos-install", "--root", "/mnt", "--flake", "/mnt/etc/nixos#nixos" ], buf) GLib.idle_add(self.show_complete) # ── Step 6: Complete ────────────────────────────────────────────────── def show_complete(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.pack_start(self.make_header("Installation Complete! 🎉", "Welcome to Sovran SystemsOS."), False, False, 0) body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) body.set_margin_start(40) body.set_margin_end(40) body.set_margin_top(16) icon = Gtk.Label() icon.set_markup("🎉") body.pack_start(icon, False, False, 0) creds_frame = Gtk.Frame(label=" Write down your login details ") creds_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) creds_inner.set_margin_top(10) creds_inner.set_margin_bottom(10) creds_inner.set_margin_start(16) creds_inner.set_margin_end(16) user_lbl = Gtk.Label() user_lbl.set_markup("Username: free") user_lbl.set_halign(Gtk.Align.START) creds_inner.pack_start(user_lbl, False, False, 0) pass_lbl = Gtk.Label() pass_lbl.set_markup("Password: free") pass_lbl.set_halign(Gtk.Align.START) creds_inner.pack_start(pass_lbl, False, False, 0) creds_frame.add(creds_inner) body.pack_start(creds_frame, False, False, 0) note = Gtk.Label() note.set_markup( "🚨 Do not lose this password — you will be permanently locked out if you forget it.\n\n" "📁 After rebooting, your system will finish setting up and save all app passwords\n" "(Nextcloud, Bitcoin, Matrix, etc.) to a secure PDF in your Documents folder." ) note.set_line_wrap(True) note.set_max_width_chars(65) note.set_justify(Gtk.Justification.CENTER) body.pack_start(note, False, False, 0) page.pack_start(body, True, True, 0) reboot_btn = Gtk.Button(label="Reboot Now") reboot_btn.get_style_context().add_class("suggested-action") reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"])) nav = Gtk.Box() nav.set_margin_bottom(20) nav.set_margin_end(40) nav.pack_end(reboot_btn, False, False, 0) page.pack_start(nav, False, False, 0) self.set_page(page, "complete") return False if __name__ == "__main__": win = InstallerWindow() Gtk.main()