added python installer and common

This commit is contained in:
2026-03-29 12:02:25 -05:00
parent 49aa27bbc8
commit dea2ab6784
3 changed files with 345 additions and 338 deletions

View File

@@ -16,11 +16,9 @@ in
image.baseName = lib.mkForce "Sovran_SystemsOS"; image.baseName = lib.mkForce "Sovran_SystemsOS";
isoImage.splashImage = ./assets/splash-logo.png; isoImage.splashImage = ./assets/splash-logo.png;
# Disable GNOME first-run tour and initial setup
services.gnome.gnome-initial-setup.enable = false; services.gnome.gnome-initial-setup.enable = false;
environment.gnome.excludePackages = with pkgs; [ gnome-tour gnome-user-docs ]; environment.gnome.excludePackages = with pkgs; [ gnome-tour gnome-user-docs ];
# Passwordless sudo for live ISO session
security.sudo.wheelNeedsPassword = false; security.sudo.wheelNeedsPassword = false;
users.users.free = { users.users.free = {
isNormalUser = true; isNormalUser = true;
@@ -37,7 +35,8 @@ in
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
installerPy installerPy
(python3.withPackages (ps: [ ps.pygobject3 ])) (python3.withPackages (ps: [ ps.pygobject3 ]))
gtk3 gtk4
libadwaita
gobject-introspection gobject-introspection
util-linux util-linux
disko disko

View File

@@ -1,67 +1,73 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Pango, GdkPixbuf gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf, Gio
import subprocess import subprocess
import threading import threading
import os import os
import sys
LOGO = "/etc/sovran/logo.png" LOGO = "/etc/sovran/logo.png"
LOG = "/tmp/sovran-install.log" LOG = "/tmp/sovran-install.log"
FLAKE = "/etc/sovran/flake" FLAKE = "/etc/sovran/flake"
logfile = open(LOG, "a") logfile = open(LOG, "a")
def log(msg): def log(msg):
logfile.write(msg + "\n") logfile.write(msg + "\n")
logfile.flush() logfile.flush()
def run(cmd, **kwargs): def run(cmd):
log(f"$ {' '.join(cmd)}") log(f"$ {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) result = subprocess.run(cmd, capture_output=True, text=True)
log(result.stdout) log(result.stdout)
if result.returncode != 0: if result.returncode != 0:
log(result.stderr) log(result.stderr)
raise RuntimeError(result.stderr or f"Command failed: {cmd}") raise RuntimeError(result.stderr.strip() or f"Command failed: {' '.join(cmd)}")
return result.stdout.strip() return result.stdout.strip()
def run_stream(cmd, text_buffer): def run_stream(cmd, buf):
log(f"$ {' '.join(cmd)}") log(f"$ {' '.join(cmd)}")
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout: for line in proc.stdout:
log(line.rstrip()) log(line.rstrip())
GLib.idle_add(append_text, text_buffer, line) GLib.idle_add(append_text, buf, line)
proc.wait() proc.wait()
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"Command failed with code {proc.returncode}. See {LOG}") raise RuntimeError(f"Command failed (exit {proc.returncode}). See {LOG}")
def append_text(buf, text): def append_text(buf, text):
buf.insert(buf.get_end_iter(), text) buf.insert(buf.get_end_iter(), text)
return False return False
def human_size(nbytes): def human_size(nbytes):
for unit in ["B","KB","MB","GB","TB"]: for unit in ["B", "KB", "MB", "GB", "TB"]:
if nbytes < 1024: if nbytes < 1024:
return f"{nbytes:.1f} {unit}" return f"{nbytes:.1f} {unit}"
nbytes /= 1024 nbytes /= 1024
return f"{nbytes:.1f} PB" return f"{nbytes:.1f} PB"
# ── Window base ────────────────────────────────────────────────────────────────
class InstallerWindow(Gtk.Window): # ── Application ────────────────────────────────────────────────────────────────
class InstallerApp(Adw.Application):
def __init__(self): def __init__(self):
super().__init__(title="Sovran_SystemsOS Installer") super().__init__(application_id="com.sovransystems.installer")
self.set_default_size(800, 560) self.connect("activate", self.on_activate)
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() def on_activate(self, app):
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) self.win = InstallerWindow(application=app)
self.stack.set_transition_duration(300) self.win.present()
self.add(self.stack)
# ── Main Window ────────────────────────────────────────────────────────────────
class InstallerWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_title("Sovran_SystemsOS Installer")
self.set_default_size(820, 600)
self.set_resizable(False)
self.role = None self.role = None
self.boot_disk = None self.boot_disk = None
@@ -69,148 +75,133 @@ class InstallerWindow(Gtk.Window):
self.data_disk = None self.data_disk = None
self.data_size = None self.data_size = None
self.show_welcome() # Root navigation view
self.show_all() self.nav = Adw.NavigationView()
self.set_content(self.nav)
# ── Helpers ──────────────────────────────────────────────────────────── self.push_welcome()
def clear_stack(self): # ── Navigation helpers ─────────────────────────────────────────────────
for child in self.stack.get_children():
self.stack.remove(child)
def set_page(self, widget, name="page"): def push_page(self, title, child, show_back=False):
self.clear_stack() page = Adw.NavigationPage(title=title, tag=title)
self.stack.add_named(widget, name) toolbar = Adw.ToolbarView()
self.stack.set_visible_child_name(name)
self.show_all()
def make_header(self, title, subtitle=None): header = Adw.HeaderBar()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) header.set_show_end_title_buttons(False)
box.set_margin_top(32) if not show_back:
box.set_margin_bottom(16) header.set_show_start_title_buttons(False)
toolbar.add_top_bar(header)
toolbar.set_content(child)
page.set_child(toolbar)
self.nav.push(page)
def replace_page(self, title, child):
# Pop all and push fresh
while self.nav.get_visible_page() is not None:
try:
self.nav.pop()
except Exception:
break
self.push_page(title, child)
# ── Shared widgets ─────────────────────────────────────────────────────
def make_scrolled_log(self):
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sw.set_vexpand(True)
tv = Gtk.TextView()
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
tv.set_monospace(True)
tv.add_css_class("log-view")
buf = tv.get_buffer()
def on_changed(b):
GLib.idle_add(lambda: sw.get_vadjustment().set_value(
sw.get_vadjustment().get_upper()
))
buf.connect("changed", on_changed)
sw.set_child(tv)
return sw, buf
def nav_row(self, back_label=None, back_cb=None, next_label="Continue", next_cb=None):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
box.set_margin_top(12)
box.set_margin_bottom(24)
box.set_margin_start(40) box.set_margin_start(40)
box.set_margin_end(40) box.set_margin_end(40)
lbl = Gtk.Label()
lbl.set_markup(f"<span font='24' weight='heavy'>{title}</span>")
lbl.set_halign(Gtk.Align.START)
box.pack_start(lbl, False, False, 0)
if subtitle:
sub = Gtk.Label()
sub.set_markup(f"<span font='13' foreground='#888888'>{subtitle}</span>")
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: if back_label and back_cb:
btn = Gtk.Button(label=back_label) btn = Gtk.Button(label=back_label)
btn.connect("clicked", back_cb) btn.connect("clicked", back_cb)
bar.pack_start(btn, False, False, 0) box.append(btn)
bar.pack_start(Gtk.Label(), True, True, 0) spacer = Gtk.Label()
spacer.set_hexpand(True)
box.append(spacer)
if next_cb: if next_cb:
btn = Gtk.Button(label=next_label) btn = Gtk.Button(label=next_label)
btn.get_style_context().add_class("suggested-action") btn.add_css_class("suggested-action")
btn.add_css_class("pill")
btn.connect("clicked", next_cb) btn.connect("clicked", next_cb)
bar.pack_end(btn, False, False, 0) box.append(btn)
return bar return box
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("<span font='48'>❌</span>")
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"<span font='11' foreground='#888'>Full log: {LOG}</span>")
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 ───────────────────────────────────────────── # ── Step 1: Welcome & Role ─────────────────────────────────────────────
def show_welcome(self): def push_welcome(self):
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Hero banner # Hero
hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
hero.set_margin_top(36) hero.set_margin_top(32)
hero.set_margin_bottom(20) hero.set_margin_bottom(24)
hero.set_halign(Gtk.Align.CENTER)
if os.path.exists(LOGO): if os.path.exists(LOGO):
try: try:
pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(LOGO, 80, 80, True) img = Gtk.Image.new_from_file(LOGO)
img = Gtk.Image.new_from_pixbuf(pb) img.set_pixel_size(96)
hero.pack_start(img, False, False, 0) hero.append(img)
except Exception: except Exception:
pass pass
title = Gtk.Label() title = Gtk.Label()
title.set_markup("<span font='28' weight='heavy'>Sovran Systems</span>") title.set_markup("<span size='xx-large' weight='heavy'>Sovran Systems</span>")
hero.pack_start(title, False, False, 0) hero.append(title)
sub = Gtk.Label() sub = Gtk.Label()
sub.set_markup("<span font='14' style='italic' foreground='#888888'>Be Digitally Sovereign</span>") sub.set_markup("<span size='large' style='italic' foreground='#888888'>Be Digitally Sovereign</span>")
hero.pack_start(sub, False, False, 0) hero.append(sub)
page.pack_start(hero, False, False, 0) outer.append(hero)
sep = Gtk.Separator() sep = Gtk.Separator()
sep.set_margin_start(40) sep.set_margin_start(40)
sep.set_margin_end(40) sep.set_margin_end(40)
page.pack_start(sep, False, False, 0) outer.append(sep)
# Role selection # Role label
lbl = Gtk.Label() role_lbl = Gtk.Label()
lbl.set_markup("<span font='13'>Select your installation type:</span>") role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
lbl.set_margin_top(20) role_lbl.set_halign(Gtk.Align.START)
lbl.set_margin_start(40) role_lbl.set_margin_start(40)
lbl.set_halign(Gtk.Align.START) role_lbl.set_margin_top(20)
page.pack_start(lbl, False, False, 0) role_lbl.set_margin_bottom(8)
outer.append(role_lbl)
# Role cards
roles = [ roles = [
("Server + Desktop", ("Server + Desktop",
"The full Sovran experience: beautiful desktop + your own cloud, secure messaging, Bitcoin node, and more.", "Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
"Server+Desktop"), "Server+Desktop"),
("Desktop Only", ("Desktop Only",
"A beautiful, easy-to-use desktop without the background server applications.", "A beautiful, easy-to-use desktop without the background server applications.",
@@ -220,56 +211,54 @@ class InstallerWindow(Gtk.Window):
"Node (Bitcoin-only)"), "Node (Bitcoin-only)"),
] ]
radio_group = None
self._role_radios = [] self._role_radios = []
role_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) radio_group = None
role_box.set_margin_start(40) cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
role_box.set_margin_end(40) cards_box.set_margin_start(40)
role_box.set_margin_top(8) cards_box.set_margin_end(40)
for label, desc, key in roles: for label, desc, key in roles:
row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) card = Adw.ActionRow()
row.set_margin_bottom(8) card.set_title(label)
card.set_subtitle(desc)
radio = Gtk.RadioButton(group=radio_group, label=label) radio = Gtk.CheckButton()
radio.set_name(key) radio.set_name(key)
if radio_group is None: if radio_group is None:
radio_group = radio radio_group = radio
radio.set_active(True) radio.set_active(True)
radio.get_style_context().add_class("role-radio") else:
radio.set_group(radio_group)
card.add_prefix(radio)
card.set_activatable_widget(radio)
self._role_radios.append(radio) self._role_radios.append(radio)
cards_box.append(card)
desc_lbl = Gtk.Label(label=desc) outer.append(cards_box)
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) outer.append(Gtk.Label(label="", vexpand=True))
row.pack_start(desc_lbl, False, False, 0) outer.append(self.nav_row(
role_box.pack_start(row, False, False, 0) next_label="Next →",
next_cb=self.on_role_next
))
page.pack_start(role_box, False, False, 0) self.push_page("Welcome to Sovran_SystemsOS Installer", outer)
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): def on_role_next(self, btn):
for radio in self._role_radios: for radio in self._role_radios:
if radio.get_active(): if radio.get_active():
self.role = radio.get_name() self.role = radio.get_name()
break break
self.show_disk_confirm() self.push_disk_confirm()
# ── Step 2: Disk Confirm ─────────────────────────────────────────────── # ── Step 2: Disk Confirm ───────────────────────────────────────────────
def show_disk_confirm(self): def push_disk_confirm(self):
# Detect disks
try: try:
raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"]) raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"])
except Exception as e: except Exception as e:
self.error(str(e)) self.show_error(str(e))
return return
disks = [] disks = []
@@ -281,7 +270,7 @@ class InstallerWindow(Gtk.Window):
disks.append((parts[0], int(parts[1]))) disks.append((parts[0], int(parts[1])))
if not disks: if not disks:
self.error("No valid internal drives found. USB drives are excluded.") self.show_error("No valid internal drives found. USB drives are excluded.")
return return
disks.sort(key=lambda x: x[1]) disks.sort(key=lambda x: x[1])
@@ -294,133 +283,119 @@ class InstallerWindow(Gtk.Window):
if s >= BYTES_2TB: if s >= BYTES_2TB:
self.data_disk, self.data_size = d, s self.data_disk, self.data_size = d, s
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) outer = 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) # Disk info group
body.set_margin_start(40) disk_group = Adw.PreferencesGroup()
body.set_margin_end(40) disk_group.set_title("Drives to be erased")
body.set_margin_top(8) disk_group.set_margin_top(24)
disk_group.set_margin_start(40)
disk_group.set_margin_end(40)
# Disk info box boot_row = Adw.ActionRow()
disk_frame = Gtk.Frame() boot_row.set_title("Boot Disk")
disk_frame.set_shadow_type(Gtk.ShadowType.IN) boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}")
disk_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) boot_row.add_prefix(Gtk.Image.new_from_icon_name("drive-harddisk-symbolic"))
disk_inner.set_margin_top(12) disk_group.add(boot_row)
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"<b>Boot disk:</b> /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: if self.data_disk:
data_lbl = Gtk.Label() data_row = Adw.ActionRow()
data_lbl.set_markup(f"<b>Data disk:</b> /dev/{self.data_disk} ({human_size(self.data_size)})") data_row.set_title("Data Disk")
data_lbl.set_halign(Gtk.Align.START) data_row.set_subtitle(f"/dev/{self.data_disk}{human_size(self.data_size)}")
disk_inner.pack_start(data_lbl, False, False, 0) data_row.add_prefix(Gtk.Image.new_from_icon_name("drive-harddisk-symbolic"))
disk_group.add(data_row)
else: else:
no_data = Gtk.Label() no_row = Adw.ActionRow()
no_data.set_markup("<b>Data disk:</b> none detected (requires 2TB+)") no_row.set_title("Data Disk")
no_data.set_halign(Gtk.Align.START) no_row.set_subtitle("None detected (requires 2 TB or larger)")
disk_inner.pack_start(no_data, False, False, 0) no_row.add_prefix(Gtk.Image.new_from_icon_name("drive-harddisk-symbolic"))
disk_group.add(no_row)
disk_frame.add(disk_inner) outer.append(disk_group)
body.pack_start(disk_frame, False, False, 0)
warn = Gtk.Label() # Warning banner
warn.set_markup( banner = Adw.Banner()
"<span foreground='#cc0000' weight='bold'>This action cannot be undone. " banner.set_title("All data on the above disk(s) will be permanently destroyed.")
"All existing data on the above disk(s) will be permanently destroyed.</span>" banner.set_revealed(True)
) banner.set_margin_top(16)
warn.set_line_wrap(True) banner.set_margin_start(40)
warn.set_max_width_chars(65) banner.set_margin_end(40)
warn.set_halign(Gtk.Align.START) outer.append(banner)
body.pack_start(warn, False, False, 0)
# Confirm entry # Confirm entry group
confirm_lbl = Gtk.Label(label='Type ERASE to confirm:') entry_group = Adw.PreferencesGroup()
confirm_lbl.set_halign(Gtk.Align.START) entry_group.set_title("Type ERASE to confirm")
body.pack_start(confirm_lbl, False, False, 0) entry_group.set_margin_top(16)
entry_group.set_margin_start(40)
entry_group.set_margin_end(40)
self._confirm_entry = Gtk.Entry() entry_row = Adw.EntryRow()
self._confirm_entry.set_placeholder_text("ERASE") entry_row.set_title("Confirmation")
body.pack_start(self._confirm_entry, False, False, 0) self._confirm_entry = entry_row
entry_group.add(entry_row)
page.pack_start(body, False, False, 0) outer.append(entry_group)
page.pack_start(Gtk.Label(), True, True, 0) outer.append(Gtk.Label(label="", vexpand=True))
page.pack_start(self.make_nav( outer.append(self.nav_row(
back_label="← Back", back_cb=lambda b: self.show_welcome(), back_label=" Back",
next_label="Begin Installation", next_cb=self.on_confirm_next back_cb=lambda b: self.nav.pop(),
), False, False, 0) next_label="Begin Installation",
self.set_page(page, "disks") next_cb=self.on_confirm_next
))
self.push_page("Confirm Installation", outer, show_back=True)
def on_confirm_next(self, btn): def on_confirm_next(self, btn):
if self._confirm_entry.get_text().strip() != "ERASE": if self._confirm_entry.get_text().strip() != "ERASE":
dlg = Gtk.MessageDialog( dlg = Adw.MessageDialog(
transient_for=self, transient_for=self,
modal=True, heading="Confirmation Required",
message_type=Gtk.MessageType.WARNING, body="You must type ERASE exactly to proceed."
buttons=Gtk.ButtonsType.OK,
text="You must type ERASE exactly to continue."
) )
dlg.run() dlg.add_response("ok", "OK")
dlg.destroy() dlg.present()
return return
self.show_progress("Preparing Drives", "Partitioning and formatting your drives...", self.do_partition) self.push_progress(
"Preparing Drives",
"Partitioning and formatting your drives...",
self.do_partition
)
# ── Step 3 & 5: Progress with live log ──────────────────────────────── # ── Step 3 & 5: Progress ──────────────────────────────────────────────
def show_progress(self, title, subtitle, worker_fn): def push_progress(self, title, subtitle, worker_fn):
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) outer = 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) status = Adw.StatusPage()
body.set_margin_start(40) status.set_title(title)
body.set_margin_end(40) status.set_description(subtitle)
status.set_icon_name("emblem-synchronizing-symbolic")
status.set_vexpand(False)
outer.append(status)
spinner = Gtk.Spinner() spinner = Gtk.Spinner()
spinner.set_size_request(48, 48) spinner.set_size_request(32, 32)
spinner.set_halign(Gtk.Align.CENTER)
spinner.start() spinner.start()
body.pack_start(spinner, False, False, 0) outer.append(spinner)
# Scrollable live log sw, buf = self.make_scrolled_log()
sw = Gtk.ScrolledWindow() sw.set_margin_start(40)
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sw.set_margin_end(40)
sw.set_size_request(-1, 260) sw.set_margin_top(12)
sw.set_margin_bottom(12)
sw.set_vexpand(True)
outer.append(sw)
tv = Gtk.TextView() self.push_page(title, outer)
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(): def thread_fn():
try: try:
worker_fn(buf) worker_fn(buf)
except Exception as e: except Exception as e:
self.error(str(e)) GLib.idle_add(self.show_error, str(e))
t = threading.Thread(target=thread_fn, daemon=True) threading.Thread(target=thread_fn, daemon=True).start()
t.start()
# ── Worker: partition ───────────────────────────────────────────────── # ── Worker: partition ─────────────────────────────────────────────────
@@ -448,8 +423,9 @@ class InstallerWindow(Gtk.Window):
GLib.idle_add(append_text, buf, "\n=== Writing role config ===\n") GLib.idle_add(append_text, buf, "\n=== Writing role config ===\n")
self.write_role_state() self.write_role_state()
GLib.idle_add(append_text, buf, "Done.\n")
GLib.idle_add(self.show_install_step) GLib.idle_add(self.push_ready)
def write_role_state(self): def write_role_state(self):
is_server = str(self.role == "Server+Desktop").lower() is_server = str(self.role == "Server+Desktop").lower()
@@ -470,131 +446,163 @@ class InstallerWindow(Gtk.Window):
log(proc.stdout) log(proc.stdout)
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}") 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"]) run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"])
# ── Step 4: Confirm before full install ─────────────────────────────── # ── Step 4: Ready to install ───────────────────────────────────────────
def show_install_step(self): def push_ready(self):
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) outer = 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) status = Adw.StatusPage()
body.set_margin_start(40) status.set_title("Drives Ready")
body.set_margin_end(40) status.set_description("Your drives have been partitioned successfully.")
body.set_margin_top(16) status.set_icon_name("emblem-ok-symbolic")
status.set_vexpand(True)
icon = Gtk.Label() details = Adw.PreferencesGroup()
icon.set_markup("<span font='48'>✅</span>") details.set_margin_start(40)
body.pack_start(icon, False, False, 0) details.set_margin_end(40)
info = Gtk.Label() role_row = Adw.ActionRow()
info.set_markup( role_row.set_title("Installation Type")
f"<b>Role:</b> {self.role}\n" role_row.set_subtitle(self.role)
f"<b>Boot disk:</b> /dev/{self.boot_disk} ({human_size(self.boot_size)})\n" + details.add(role_row)
(f"<b>Data disk:</b> /dev/{self.data_disk} ({human_size(self.data_size)})"
if self.data_disk else "<b>Data disk:</b> none") boot_row = Adw.ActionRow()
) boot_row.set_title("Boot Disk")
info.set_halign(Gtk.Align.CENTER) boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}")
info.set_justify(Gtk.Justification.CENTER) details.add(boot_row)
body.pack_start(info, False, False, 0)
if self.data_disk:
data_row = Adw.ActionRow()
data_row.set_title("Data Disk")
data_row.set_subtitle(f"/dev/{self.data_disk}{human_size(self.data_size)}")
details.add(data_row)
status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
status_box.append(status)
status_box.append(details)
note = Gtk.Label() note = Gtk.Label()
note.set_markup( note.set_markup(
"<span foreground='#888'>The next step will install the full system.\n" "<span foreground='#888888'>The next step will install the full system.\n"
"This may take <b>2040 minutes</b> depending on your internet speed.\n" "This may take <b>2040 minutes</b> depending on your internet speed.\n"
"Do not turn off your computer.</span>" "Do not turn off your computer.</span>"
) )
note.set_justify(Gtk.Justification.CENTER) note.set_justify(Gtk.Justification.CENTER)
note.set_line_wrap(True) note.set_wrap(True)
body.pack_start(note, False, False, 0) note.set_margin_top(16)
note.set_margin_start(40)
note.set_margin_end(40)
status_box.append(note)
page.pack_start(body, True, True, 0) outer.append(status_box)
page.pack_start(self.make_nav( outer.append(self.nav_row(
next_label="Install Now", next_cb=lambda b: self.show_progress( next_label="Install Now",
next_cb=lambda b: self.push_progress(
"Installing Sovran SystemsOS", "Installing Sovran SystemsOS",
"Building and installing your system. This will take a while...", "Building and installing your system. Please wait...",
self.do_install self.do_install
) )
), False, False, 0) ))
self.set_page(page, "ready")
self.push_page("Ready to Install", outer)
return False return False
# ── Worker: install ─────────────────────────────────────────────────── # ── Worker: install ───────────────────────────────────────────────────
def do_install(self, buf): def do_install(self, buf):
GLib.idle_add(append_text, buf, "=== Running nixos-install ===\n") GLib.idle_add(append_text, buf, "=== Running nixos-install ===\n")
for f in ["/mnt/etc/nixos/role-state.nix", "/mnt/etc/nixos/custom.nix"]:
if not os.path.exists(f):
raise RuntimeError(f"Required file missing: {f}")
run_stream([ run_stream([
"sudo", "nixos-install", "sudo", "nixos-install",
"--root", "/mnt", "--root", "/mnt",
"--flake", "/mnt/etc/nixos#nixos" "--flake", "/mnt/etc/nixos#nixos"
], buf) ], buf)
GLib.idle_add(self.show_complete)
# ── Step 6: Complete ────────────────────────────────────────────────── GLib.idle_add(self.push_complete)
def show_complete(self): # ── Step 6: Complete ───────────────────────────────────────────────────
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) def push_complete(self):
body.set_margin_start(40) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
body.set_margin_end(40)
body.set_margin_top(16)
icon = Gtk.Label() status = Adw.StatusPage()
icon.set_markup("<span font='48'>🎉</span>") status.set_title("Installation Complete!")
body.pack_start(icon, False, False, 0) status.set_description("Welcome to Sovran SystemsOS.")
status.set_icon_name("emblem-ok-symbolic")
status.set_vexpand(True)
creds_frame = Gtk.Frame(label=" Write down your login details ") creds_group = Adw.PreferencesGroup()
creds_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) creds_group.set_title("⚠ Write down your login details before rebooting")
creds_inner.set_margin_top(10) creds_group.set_margin_start(40)
creds_inner.set_margin_bottom(10) creds_group.set_margin_end(40)
creds_inner.set_margin_start(16)
creds_inner.set_margin_end(16)
user_lbl = Gtk.Label() user_row = Adw.ActionRow()
user_lbl.set_markup("<b>Username:</b> free") user_row.set_title("Username")
user_lbl.set_halign(Gtk.Align.START) user_row.set_subtitle("free")
creds_inner.pack_start(user_lbl, False, False, 0) creds_group.add(user_row)
pass_lbl = Gtk.Label() pass_row = Adw.ActionRow()
pass_lbl.set_markup("<b>Password:</b> free") pass_row.set_title("Password")
pass_lbl.set_halign(Gtk.Align.START) pass_row.set_subtitle("free")
creds_inner.pack_start(pass_lbl, False, False, 0) creds_group.add(pass_row)
creds_frame.add(creds_inner) note_row = Adw.ActionRow()
body.pack_start(creds_frame, False, False, 0) note_row.set_title("App Passwords")
note_row.set_subtitle(
note = Gtk.Label() "After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) "
note.set_markup( "will be saved to a secure PDF in your Documents folder."
"🚨 <b>Do not lose this password</b> — 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 <b>Documents</b> folder."
) )
note.set_line_wrap(True) creds_group.add(note_row)
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) content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content_box.append(status)
content_box.append(creds_group)
outer.append(content_box)
reboot_btn = Gtk.Button(label="Reboot Now") reboot_btn = Gtk.Button(label="Reboot Now")
reboot_btn.get_style_context().add_class("suggested-action") reboot_btn.add_css_class("suggested-action")
reboot_btn.add_css_class("pill")
reboot_btn.add_css_class("destructive-action")
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"])) 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") nav = Gtk.Box()
nav.set_margin_bottom(24)
nav.set_margin_end(40)
nav.set_halign(Gtk.Align.END)
nav.append(reboot_btn)
outer.append(nav)
self.push_page("Complete", outer)
return False
# ── Error screen ───────────────────────────────────────────────────────
def show_error(self, msg):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
status = Adw.StatusPage()
status.set_title("Installation Error")
status.set_description(msg)
status.set_icon_name("dialog-error-symbolic")
status.set_vexpand(True)
log_lbl = Gtk.Label()
log_lbl.set_markup(f"<span foreground='#888888' size='small'>Full log: {LOG}</span>")
log_lbl.set_margin_bottom(24)
outer.append(status)
outer.append(log_lbl)
self.push_page("Error", outer)
return False return False
if __name__ == "__main__": if __name__ == "__main__":
win = InstallerWindow() app = InstallerApp()
Gtk.main() app.run(None)

2
result
View File

@@ -1 +1 @@
/nix/store/yaw5krmqdcyw79z4d91hyg92fwdas91k-Sovran_SystemsOS.iso /nix/store/mjb94jp3bsb532zc7hsp8wf8v8s450k5-Sovran_SystemsOS.iso