AkurAI Build
Menu

BifrOSt

public

Latest change 557d1b90e285bbc40606eb989e796973e2cab034 - Build BifrOSt Icelandic COSMIC installer by Ólafur Búi Ólafsson

#!/usr/bin/env python3
import json
import os
import re
import secrets
import subprocess
import tempfile
import threading
from pathlib import Path

import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk

BASE_CONFIG = Path("/usr/share/bifrost/archinstall.json")

CSS = b"""
window { background: #07111f; color: #eef7ff; }
.hero { background: #0a1728; padding: 44px; }
.hero-title { font-size: 34px; font-weight: 800; }
.hero-subtitle { font-size: 16px; color: #a9bfd2; }
.step-title { font-size: 26px; font-weight: 750; }
.step-copy { color: #a9bfd2; font-size: 15px; }
.card { background: #0e1d30; border: 1px solid #203a53; border-radius: 14px; padding: 22px; }
.disk-row { background: #10243a; border-radius: 10px; padding: 14px; }
.warning { background: #39231f; border: 1px solid #9a513b; border-radius: 10px; padding: 14px; color: #ffd8ca; }
.install-button { background: #e76f51; color: #08111e; font-weight: 800; }
.install-button:hover { background: #f58a6b; }
.accent { color: #65d8ff; }
progressbar trough { min-height: 8px; background: #182c42; }
progressbar progress { background: #65d8ff; }
"""


def human_size(value):
    size = float(value)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if size < 1000 or unit == "TB":
            return f"{size:.1f} {unit}"
        size /= 1000


def available_disks():
    result = subprocess.run(
        ["lsblk", "--bytes", "--json", "--output", "NAME,PATH,SIZE,MODEL,TYPE,RO,RM,MOUNTPOINTS"],
        check=True, capture_output=True, text=True,
    )
    disks = []
    for item in json.loads(result.stdout)["blockdevices"]:
        mounts = [m for m in item.get("mountpoints", []) if m]
        if item["type"] == "disk" and not item.get("ro") and not item.get("rm") and not mounts:
            disks.append(item)
    return disks


def password_hash(password):
    salt = secrets.token_urlsafe(12).replace("-", "").replace("_", "")[:16]
    return subprocess.run(
        ["openssl", "passwd", "-6", "-salt", salt, "-stdin"],
        input=password + "\n", text=True, capture_output=True, check=True,
    ).stdout.strip()


class InstallerWindow(Adw.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title="Setja upp BifrOSt")
        self.set_default_size(1040, 700)
        self.set_size_request(860, 620)
        self.disks = available_disks()
        self.page_index = 0
        self.installing = False

        self.toast_overlay = Adw.ToastOverlay()
        self.set_content(self.toast_overlay)
        shell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.toast_overlay.set_child(shell)

        hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        hero.add_css_class("hero")
        hero.set_size_request(330, -1)
        shell.append(hero)
        mark = Gtk.Image.new_from_file("/usr/share/bifrost/branding/bifrost-mark.svg")
        mark.set_pixel_size(96)
        mark.set_halign(Gtk.Align.START)
        hero.append(mark)
        brand = Gtk.Label(label="BifrOSt", xalign=0)
        brand.add_css_class("hero-title")
        hero.append(brand)
        desc = Gtk.Label(label="Íslenskt, frjálst og byggt fyrir þig.", xalign=0, wrap=True)
        desc.add_css_class("hero-subtitle")
        hero.append(desc)
        hero.append(Gtk.Box(vexpand=True))
        self.step_label = Gtk.Label(label="Skref 1 af 4", xalign=0)
        self.step_label.add_css_class("accent")
        hero.append(self.step_label)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        content.set_margin_top(34); content.set_margin_bottom(28)
        content.set_margin_start(42); content.set_margin_end(42)
        content.set_hexpand(True)
        shell.append(content)

        self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, transition_duration=260)
        self.stack.set_vexpand(True)
        content.append(self.stack)
        self._welcome_page(); self._disk_page(); self._identity_page(); self._review_page(); self._progress_page()

        self.nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        content.append(self.nav)
        self.back = Gtk.Button(label="Til baka")
        self.back.connect("clicked", self._go_back)
        self.nav.append(self.back)
        self.nav.append(Gtk.Box(hexpand=True))
        self.next = Gtk.Button(label="Halda áfram")
        self.next.add_css_class("suggested-action")
        self.next.connect("clicked", self._go_next)
        self.nav.append(self.next)
        self._show_page(0)

    def _heading(self, title, copy):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        title_label = Gtk.Label(label=title, xalign=0, wrap=True)
        title_label.add_css_class("step-title")
        copy_label = Gtk.Label(label=copy, xalign=0, wrap=True)
        copy_label.add_css_class("step-copy")
        box.append(title_label); box.append(copy_label)
        return box

    def _welcome_page(self):
        page = self._heading("Velkomin í BifrOSt", "Uppsetningin tekur aðeins nokkur skref. Þú velur disk og notanda; BifrOSt sér um restina.")
        card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
        card.add_css_class("card")
        card.set_margin_top(26)
        for icon, title, text in [
            ("preferences-desktop-keyboard-symbolic", "Íslenskt frá fyrstu ræsingu", "Tungumál, lyklaborð og tímabelti eru rétt stillt."),
            ("applications-development-symbolic", "Tilbúið fyrir þróun", "Rust, Clang, Podman og nauðsynleg verkfæri fylgja."),
            ("security-high-symbolic", "Öruggur Arch grunnur", "Opinberir pakkar og venjulegar Arch uppfærslur."),
        ]:
            row = Gtk.Box(spacing=14)
            row.append(Gtk.Image.new_from_icon_name(icon))
            labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
            labels.append(Gtk.Label(label=title, xalign=0, css_classes=["heading"]))
            labels.append(Gtk.Label(label=text, xalign=0, wrap=True, css_classes=["dim-label"]))
            row.append(labels); card.append(row)
        page.append(card)
        self.stack.add_named(page, "welcome")

    def _disk_page(self):
        page = self._heading("Hvar á að setja BifrOSt?", "Veldu heilan disk. Öllum gögnum á valda disknum verður eytt.")
        card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        card.add_css_class("card"); card.set_margin_top(22)
        self.disk_combo = Gtk.DropDown()
        labels = [f"{d.get('model') or d['name']} — {human_size(d['size'])} ({d['path']})" for d in self.disks]
        self.disk_combo.set_model(Gtk.StringList.new(labels or ["Enginn hentugur diskur fannst"]))
        self.disk_combo.set_sensitive(bool(self.disks))
        card.append(self.disk_combo)
        note = Gtk.Label(label="Hreint Btrfs kerfi, EFI ræsing og þjöppuð zram swap verða útbúin sjálfkrafa.", xalign=0, wrap=True)
        note.add_css_class("dim-label"); card.append(note)
        page.append(card)
        warn = Gtk.Label(label="Varúð: Þetta er eyðandi aðgerð. Afritaðu mikilvæg gögn áður en þú heldur áfram.", xalign=0, wrap=True)
        warn.add_css_class("warning"); warn.set_margin_top(16); page.append(warn)
        self.stack.add_named(page, "disk")

    def _entry_row(self, label, entry):
        row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        row.append(Gtk.Label(label=label, xalign=0)); row.append(entry)
        return row

    def _identity_page(self):
        page = self._heading("Stofnaðu notandann þinn", "Þessi aðgangur fær stjórnandaréttindi og verður notaður til daglegra starfa.")
        grid = Gtk.Grid(column_spacing=16, row_spacing=14)
        grid.add_css_class("card"); grid.set_margin_top(20)
        self.fullname = Gtk.Entry(placeholder_text="Fullt nafn")
        self.username = Gtk.Entry(placeholder_text="notandanafn")
        self.hostname = Gtk.Entry(text="bifrost", placeholder_text="tölvuheiti")
        self.password = Gtk.PasswordEntry(show_peek_icon=True)
        self.password2 = Gtk.PasswordEntry(show_peek_icon=True)
        grid.attach(self._entry_row("Nafn", self.fullname), 0, 0, 1, 1)
        grid.attach(self._entry_row("Notandanafn", self.username), 1, 0, 1, 1)
        grid.attach(self._entry_row("Lykilorð", self.password), 0, 1, 1, 1)
        grid.attach(self._entry_row("Staðfesta lykilorð", self.password2), 1, 1, 1, 1)
        grid.attach(self._entry_row("Heiti tölvu", self.hostname), 0, 2, 2, 1)
        page.append(grid)
        self.stack.add_named(page, "identity")

    def _review_page(self):
        page = self._heading("Tilbúið til uppsetningar", "Farðu yfir valið. Eftir að uppsetning hefst er ekki hægt að hætta án þess að skilja diskinn eftir breyttan.")
        self.summary = Gtk.Label(xalign=0, yalign=0, wrap=True, selectable=True)
        self.summary.add_css_class("card"); self.summary.set_margin_top(20)
        page.append(self.summary)
        self.confirm = Gtk.CheckButton(label="Ég skil að öllum gögnum á valda disknum verður eytt.")
        self.confirm.set_margin_top(16); self.confirm.connect("toggled", self._confirm_changed)
        page.append(self.confirm)
        self.stack.add_named(page, "review")

    def _progress_page(self):
        page = self._heading("Set BifrOSt upp", "Sækir pakka og útbýr nýja kerfið. Þetta getur tekið nokkrar mínútur.")
        self.progress = Gtk.ProgressBar(show_text=True)
        self.progress.set_text("Undirbý uppsetningu…")
        self.progress.set_margin_top(40); page.append(self.progress)
        self.status = Gtk.Label(label="Ekki slökkva á tölvunni.", xalign=0, wrap=True)
        self.status.add_css_class("step-copy"); self.status.set_margin_top(16); page.append(self.status)
        self.stack.add_named(page, "progress")

    def _toast(self, text):
        self.toast_overlay.add_toast(Adw.Toast(title=text, timeout=4))

    def _show_page(self, index):
        names = ["welcome", "disk", "identity", "review", "progress"]
        self.page_index = index
        self.stack.set_visible_child_name(names[index])
        self.step_label.set_label(f"Skref {min(index + 1, 4)} af 4")
        self.back.set_visible(index > 0 and index < 4)
        self.next.set_visible(index < 4)
        self.next.set_label("Setja upp BifrOSt" if index == 3 else "Halda áfram")
        self.next.remove_css_class("install-button")
        if index == 3:
            self.next.add_css_class("install-button")
            self.next.set_sensitive(self.confirm.get_active())
        else:
            self.next.set_sensitive(True)

    def _go_back(self, _button):
        if not self.installing and self.page_index > 0:
            self._show_page(self.page_index - 1)

    def _validate_identity(self):
        username = self.username.get_text().strip()
        hostname = self.hostname.get_text().strip()
        if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username):
            self._toast("Notandanafn má aðeins innihalda lágstafi, tölur, _ og -."); return False
        if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,62}", hostname):
            self._toast("Ógilt tölvuheiti."); return False
        if len(self.password.get_text()) < 8:
            self._toast("Lykilorð þarf að vera að minnsta kosti 8 stafir."); return False
        if self.password.get_text() != self.password2.get_text():
            self._toast("Lykilorðin passa ekki saman."); return False
        return True

    def _go_next(self, _button):
        if self.page_index == 1 and not self.disks:
            self._toast("Enginn auður uppsetningardiskur fannst."); return
        if self.page_index == 2 and not self._validate_identity():
            return
        if self.page_index == 2:
            disk = self.disks[self.disk_combo.get_selected()]
            self.summary.set_markup(
                f"<b>Diskur</b>\n{GLib.markup_escape_text(disk.get('model') or disk['name'])} — {human_size(disk['size'])}\n\n"
                f"<b>Notandi</b>\n{GLib.markup_escape_text(self.fullname.get_text() or self.username.get_text())} ({GLib.markup_escape_text(self.username.get_text())})\n\n"
                f"<b>Tölva</b>\n{GLib.markup_escape_text(self.hostname.get_text())}\n\n"
                "<b>Stillingar</b>\nÍslenska · Atlantic/Reykjavik · COSMIC · Btrfs"
            )
        if self.page_index == 3:
            self._start_install(); return
        self._show_page(self.page_index + 1)

    def _confirm_changed(self, button):
        if self.page_index == 3:
            self.next.set_sensitive(button.get_active())

    def _start_install(self):
        self.installing = True
        self._show_page(4)
        self.nav.set_visible(False)
        self.progress.pulse()
        GLib.timeout_add(500, self._pulse)
        threading.Thread(target=self._install_worker, daemon=True).start()

    def _pulse(self):
        if self.installing:
            self.progress.pulse(); return GLib.SOURCE_CONTINUE
        return GLib.SOURCE_REMOVE

    def _install_worker(self):
        try:
            config = json.loads(BASE_CONFIG.read_text())
            selected_disk = self.disks[self.disk_combo.get_selected()]
            disk = selected_disk["path"]
            root_start_sectors = 1025 * 1024 * 1024 // 512
            root_length_sectors = (
                (int(selected_disk["size"]) // 512 - root_start_sectors - 34) // 2048
            ) * 2048
            config["hostname"] = self.hostname.get_text().strip()
            config["disk_config"] = {
                "config_type": "default_layout",
                "device_modifications": [{"device": disk, "wipe": True, "partitions": [
                    {"btrfs": [], "dev_path": None, "flags": ["boot", "esp"], "fs_type": "fat32", "mount_options": [], "mountpoint": "/boot", "obj_id": "bifrost-efi", "size": {"unit": "MiB", "value": 1024, "sector_size": {"unit": "B", "value": 512}}, "start": {"unit": "MiB", "value": 1, "sector_size": {"unit": "B", "value": 512}}, "status": "create", "type": "primary"},
                    {"btrfs": [{"mountpoint": "/", "name": "@"}, {"mountpoint": "/home", "name": "@home"}, {"mountpoint": "/var/log", "name": "@log"}, {"mountpoint": "/.snapshots", "name": "@snapshots"}], "dev_path": None, "flags": [], "fs_type": "btrfs", "mount_options": ["compress=zstd"], "mountpoint": None, "obj_id": "bifrost-root", "size": {"unit": "sectors", "value": root_length_sectors, "sector_size": {"unit": "B", "value": 512}}, "start": {"unit": "sectors", "value": root_start_sectors, "sector_size": {"unit": "B", "value": 512}}, "status": "create", "type": "primary"}
                ]}]
            }
            creds = {"users": [{"username": self.username.get_text().strip(), "sudo": True, "enc_password": password_hash(self.password.get_text())}]}
            with tempfile.TemporaryDirectory(prefix="bifrost-installer-") as tmp:
                config_path = Path(tmp) / "config.json"; creds_path = Path(tmp) / "creds.json"
                config_path.write_text(json.dumps(config)); creds_path.write_text(json.dumps(creds))
                os.chmod(config_path, 0o600); os.chmod(creds_path, 0o600)
                proc = subprocess.run(["sudo", "/usr/local/lib/bifrost-installer-backend", str(config_path), str(creds_path)], text=True, capture_output=True)
                if proc.returncode:
                    raise RuntimeError((proc.stderr or proc.stdout)[-1200:] or f"Villa {proc.returncode}")
            GLib.idle_add(self._install_done)
        except Exception as exc:
            GLib.idle_add(self._install_failed, str(exc))

    def _install_done(self):
        self.installing = False
        self.progress.set_fraction(1); self.progress.set_text("Uppsetningu lokið")
        self.status.set_markup("<b>BifrOSt er tilbúið.</b> Fjarlægðu uppsetningarmiðilinn og endurræstu tölvuna.")

    def _install_failed(self, error):
        self.installing = False
        self.progress.set_fraction(0); self.progress.set_text("Uppsetning mistókst")
        self.status.set_text(error)
        self._toast("Uppsetning mistókst. Nánari upplýsingar birtast á skjánum.")


class InstallerApp(Adw.Application):
    def __init__(self):
        super().__init__(application_id="is.bifrost.Installer")

    def do_startup(self):
        Adw.Application.do_startup(self)
        provider = Gtk.CssProvider(); provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def do_activate(self):
        window = self.props.active_window or InstallerWindow(self)
        window.present()


if __name__ == "__main__":
    raise SystemExit(InstallerApp().run())