From b781cf8c974ba9cdedaee3efe213451313ffcbf0 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 26 Oct 2026 16:01:38 +0100 Subject: [PATCH] feat: add MontaukOS Imager tool for Raspberry Pi --- GNUmakefile | 6 + docs/raspberry-pi.md | 44 + .../montauk-imager.cpython-314.pyc | Bin 0 -> 121316 bytes scripts/montauk-imager.py | 1720 +++++++++++++++++ 4 files changed, 1770 insertions(+) create mode 100644 scripts/__pycache__/montauk-imager.cpython-314.pyc create mode 100755 scripts/montauk-imager.py diff --git a/GNUmakefile b/GNUmakefile index 0d3b5c4..ea13dbe 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -429,6 +429,12 @@ ifneq ($(ARCH),aarch64) endif ./scripts/mk-rpi4-sd.sh kernel/bin-$(ARCH)/kernel ramdisk.tar $@ $(ROOTFS) $(PROGRAMS_BIN)-rpi +# Graphical imager: write montauk-rpi4.img to an SD card, or update the +# installation on one in place (scripts/montauk-imager.py, docs/raspberry-pi.md). +.PHONY: rpi4-imager +rpi4-imager: + ./scripts/montauk-imager.py + $(IMAGE_NAME).hdd: limine/limine kernel ramdisk rm -f $(IMAGE_NAME).hdd dd if=/dev/zero bs=1M count=0 seek=64 of=$(IMAGE_NAME).hdd diff --git a/docs/raspberry-pi.md b/docs/raspberry-pi.md index 958fad3..e0496fd 100644 --- a/docs/raspberry-pi.md +++ b/docs/raspberry-pi.md @@ -12,6 +12,50 @@ make ARCH=aarch64 rpi4-sd # -> montauk-rpi4.img sudo dd if=montauk-rpi4.img of=/dev/sdX bs=4M conv=fsync ``` +Or use the imager, which also updates a card in place: + +```bash +make rpi4-imager # = ./scripts/montauk-imager.py +``` + +### The imager + +`scripts/montauk-imager.py` is a Tk window (`python3-tk`) that lists the SD +cards and USB drives attached (never the host's own disks) and what is on +them, and either writes a fresh image or updates the MontaukOS on a card. It +runs as the user and does the writing by starting itself again through +`pkexec` (it asks for the password each time). The same operations work from +a terminal: `list`, and as root `flash`, `update` and `expand` +(`--help` for the options). Images can be raw or `.gz`, `.xz` or `.zip` +compressed; **Build** runs `make ARCH=aarch64 rpi4-sd`. + +- **Write a fresh image** erases the card, writes the image, clears the last + MiB of the card (a backup GPT from an earlier OS) and reads the card back to + verify it. *Expand* grows the root to fill the card right away instead of + leaving it to `firstboot.elf`. +- **Update** leaves the partition table alone and finds both partitions by + label, so a root that has grown to fill the card (or was resized by hand) + stays as it is. On the ESP every file is written except `RPI_EFI.fd`, which + holds the firmware's own settings (*Update the UEFI firmware* replaces it); + a changed `config.txt` is kept as `config.txt.bak`. On the root, `os/`, + `apps/`, `sdk/`, `boot/` get the image's new and changed files; `config/` + and `www/` only get files the card lacks, `users/` and `state/` are not + touched. `init.toml` becomes the image's, keeping each service's `enabled` + from the card and appending services only the card has; `capabilities.toml` + becomes the image's plus grants only the card has (old copies: `.old`). + `os/firstboot.elf` is only written if the card still has it, so a grown + card does not grow again. *Remove system files the new image no longer + ships* prunes `os/`, `apps/`, `sdk/` and `boot/` (keeping `os/btkeys.bin`, + the Bluetooth bonds). Unchanged files are skipped and each file is + replaced by a rename; the volume is checked with `e2fsck` before and after, + and its features against what `Fs/Ext2.cpp` mounts. +- **Expand** (a host-side first boot) grows the root partition to the end of + the card, at most 1 TiB, and the volume with `resize2fs`; `firstboot.elf` + then finds nothing to do and deletes itself. + +While it works, a runtime udev rule keeps the desktop from automounting the +card's partitions (Linux writes to an ext2 volume as soon as it mounts it). + `scripts/mk-rpi4-sd.sh` downloads the firmware release once into `rpi4-firmware/` (`PFTF_VERSION=v1.53` by default) and builds an MBR image: diff --git a/scripts/__pycache__/montauk-imager.cpython-314.pyc b/scripts/__pycache__/montauk-imager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e229cc59b80e4614910bde4fa054327a32e98e31 GIT binary patch literal 121316 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 Bp7#I% literal 0 HcmV?d00001 diff --git a/scripts/montauk-imager.py b/scripts/montauk-imager.py new file mode 100755 index 0000000..a7fdd50 --- /dev/null +++ b/scripts/montauk-imager.py @@ -0,0 +1,1720 @@ +#!/usr/bin/env python3 +# montauk-imager.py - write and update MontaukOS SD cards for the Raspberry Pi 4/400 +# +# Usage: ./scripts/montauk-imager.py [--image ] the graphical imager +# ./scripts/montauk-imager.py list list SD cards / USB drives +# sudo ./scripts/montauk-imager.py flash [--no-verify] [--expand] [--yes] +# sudo ./scripts/montauk-imager.py update [--keep-config] [--firmware] +# [--prune] [--expand] [--yes] +# sudo ./scripts/montauk-imager.py expand [--yes] +# +# The image is what `make ARCH=aarch64 rpi4-sd` builds (montauk-rpi4.img, see +# scripts/mk-rpi4-sd.sh and docs/raspberry-pi.md), raw or compressed (.gz, .xz, +# .zip). The GUI runs as the user and does the work by starting this script +# again through pkexec with --machine, reading its progress from stdout. +# +# flash Writes the image over the whole card (cancellable), wipes the end of +# the card (a stale backup GPT from an earlier OS), reads it back to +# verify, and rereads the partition table. +# +# update Refreshes the installation on a card already written from an image, +# in place: the card's partition table is never touched, so a system +# partition that has grown to fill the card (first boot, or --expand) +# stays as it is. Both partitions are found by their labels. +# ESP (FAT32, MONTAUK) every file of the image's ESP is written; +# config.txt is backed up to config.txt.bak +# if it differs, and the UEFI firmware +# RPI_EFI.fd (which holds the firmware's own +# settings) is only replaced with --firmware. +# root (ext2, MONTAUKROOT) +# os/, apps/, sdk/, boot/ and any other system +# directory: new and changed files written. +# config/ and www/: only files the card lacks +# are added, except that the image's init.toml +# and capabilities.toml are merged in (below). +# users/ and state/ are never touched. +# init.toml becomes the image's, with each service's `enabled` taken +# from the card's copy and services only the card has appended; +# capabilities.toml becomes the image's with grants only the card has +# appended. The card's old copy is kept as .old. --keep-config +# leaves both alone. os/firstboot.elf is only written if the card still +# has it: it deletes itself once the volume has grown. --prune deletes +# files of the system directories the image no longer ships (except +# os/btkeys.bin, the Bluetooth bonds the kernel keeps there). +# Unchanged files are not rewritten; each file is written to a temporary +# name, flushed and renamed over the old one. +# +# expand Grows the system partition to the end of the card (on a 1 MiB +# boundary, at most 1 TiB, the size mk-rpi4-sd.sh's resize_inode +# reserves for) and the ext2 volume with resize2fs, which is what +# 0:/os/firstboot.elf otherwise does on the Pi. firstboot.elf then +# finds nothing to grow and deletes itself. flash and update take +# --expand to do this as a last (flash) or first (update) step. +# +# The ext2 volume is checked with e2fsck before it is changed and afterwards, +# and its feature flags are checked against what Fs/Ext2.cpp mounts (no +# incompat feature but filetype). + +import argparse +import contextlib +import errno +import fcntl +import filecmp +import gzip +import hashlib +import json +import lzma +import os +import queue +import re +import shutil +import signal +import stat +import struct +import subprocess +import sys +import tempfile +import threading +import time +import zipfile + +ROOT_LABEL = "MONTAUKROOT" # RootLabel in kernel/src/Fs/Boot.cpp +ESP_LABEL = "MONTAUK" # mformat -v in scripts/mk-rpi4-sd.sh +SECTOR = 512 +MIB = 1 << 20 +ALIGN = MIB // SECTOR # partitions start and end on 1 MiB boundaries +MAX_ROOT_BYTES = (1 << 28) * 4096 # mke2fs -E resize=2^28 (4 KiB blocks); the kernel's grow cap too +CHUNK = 4 * MIB + +FAT_TYPES = {0x01, 0x04, 0x06, 0x0B, 0x0C, 0x0E, 0xEF} +TYPE_ESP = 0xEF +TYPE_LINUX = 0x83 + +# ext2/3/4 incompat features (dumpe2fs names). Fs/Ext2.cpp refuses all but filetype. +EXT_INCOMPAT = { + "compression", "filetype", "needs_recovery", "journal_dev", "meta_bg", + "extent", "extents", "64bit", "mmp", "flex_bg", "ea_inode", "dirdata", + "metadata_csum_seed", "large_dir", "inline_data", "encrypt", "casefold", +} +SUPPORTED_INCOMPAT = {"filetype"} + +# Mount points that mark a disk as the host's own: never offered, never written. +SYSTEM_MOUNTS = {"/", "/boot", "/boot/efi", "/efi", "/usr", "/var", "/home", "[SWAP]"} + +# Update policy for the root volume (see the header). +MERGE_DIRS = {"config", "www"} +STATE_DIRS = {"users", "state", "lost+found"} +PRUNE_DIRS = {"os", "apps", "sdk", "boot"} +PRESERVE = {"os/btkeys.bin", "os/firstboot.elf"} +FIRSTBOOT = "os/firstboot.elf" +INIT_TOML = "config/init.toml" +CAPS_TOML = "config/capabilities.toml" +FIRMWARE_FILE = "RPI_EFI.fd" +ESP_BACKUP = {"config.txt"} + +BLKRRPART = 0x125F +BLKFLSBUF = 0x1261 + +SCRIPT = os.path.realpath(__file__) +REPO = os.path.dirname(os.path.dirname(SCRIPT)) +if not os.path.isfile(os.path.join(REPO, "scripts", "mk-rpi4-sd.sh")): + REPO = None +DEFAULT_IMAGE = os.path.join(REPO, "montauk-rpi4.img") if REPO else "" + + +class Fail(Exception): + pass + + +def human(n): + """Decimal units, as SD cards are labelled.""" + n = float(n) + for unit in ("B", "kB", "MB", "GB", "TB"): + if n < 1000 or unit == "TB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1000 + + +def tool_env(): + env = dict(os.environ) + env["PATH"] = env.get("PATH", "/usr/bin:/bin") + ":/usr/sbin:/sbin" + env["LC_ALL"] = "C" + return env + + +# ==== Output ==== + +class Reporter: + """Progress for the GUI (--machine: one '@tag text' line each) or a terminal.""" + + def __init__(self, machine): + self.machine = machine + self.cancel = threading.Event() + self.cancellable = False + self._last = 0.0 + self._inline = False + self._tty = sys.stdout.isatty() + + def _write(self, line, end="\n"): + try: + sys.stdout.write(line + end) + sys.stdout.flush() + except (BrokenPipeError, OSError): + # The GUI went away. Finish the job anyway: an update stopped + # halfway is worse than one nobody watches. + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + + def _emit(self, tag, text): + text = " ".join(str(text).splitlines()) if tag != "log" else str(text).rstrip("\n") + if self.machine: + for part in text.splitlines() or [""]: + self._write(f"@{tag} {part}") + return + if self._inline: + self._write("") + self._inline = False + prefix = {"stage": "==> ", "log": " ", "warn": "warning: ", + "error": "error: ", "done": "==> "}[tag] + self._write(prefix + text) + + def stage(self, text): self._emit("stage", text) + def log(self, text): self._emit("log", text) + def warn(self, text): self._emit("warn", text) + def error(self, text): self._emit("error", text) + def done(self, text): self._emit("done", text) + + def progress(self, done, total, text, force=False): + now = time.monotonic() + if not force and done < total and now - self._last < 0.2: + return + self._last = now + if self.machine: + self._write(f"@progress {int(done)} {int(max(total, 1))} {text}") + elif self._tty: + pct = 100 * done / max(total, 1) + self._write(f"\r[{pct:5.1f}%] {text}".ljust(78), end="") + self._inline = True + + def check_cancel(self): + if self.cancellable and self.cancel.is_set(): + raise Fail("cancelled") + + +def watch_stdin(rep): + """The GUI cancels by writing 'cancel' (or by closing the pipe).""" + def run(): + try: + for line in sys.stdin: + if line.strip() == "cancel": + rep.cancel.set() + except (OSError, ValueError): + pass + rep.cancel.set() + threading.Thread(target=run, daemon=True).start() + + +def run(rep, cmd, ok=(0,), quiet=False): + try: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, text=True, errors="replace", env=tool_env()) + except FileNotFoundError: + raise Fail(f"{cmd[0]} is not installed") + if not quiet or p.returncode not in ok: + for line in p.stdout.splitlines(): + if line.strip(): + rep.log(line) + if p.returncode not in ok: + raise Fail(f"{os.path.basename(cmd[0])} failed (exit {p.returncode})") + return p + + +# ==== Partition tables ==== + +class Part: + def __init__(self, number, ptype, start, sectors): + self.number, self.type, self.start, self.sectors = number, ptype, start, sectors + self.node = None + self.label = "" + self.fstype = "" + + @property + def offset(self): return self.start * SECTOR + + @property + def size(self): return self.sectors * SECTOR + + @property + def end(self): return self.start + self.sectors + + +def parse_mbr(sector0): + """The four primary entries of an MBR, or None. Raises on a GPT disk.""" + if len(sector0) < 512 or sector0[510:512] != b"\x55\xaa": + return None + parts = [] + for i in range(4): + e = sector0[446 + 16 * i: 462 + 16 * i] + ptype = e[4] + start, count = struct.unpack_from(" root.start for p in parts): + raise Fail(f"partition {root.number} is not the last one; cannot expand it") + rep.stage("Expanding the system partition") + total = disk_bytes(name) // SECTOR + end = total // ALIGN * ALIGN + count = min(end - root.start, MAX_ROOT_BYTES // SECTOR, 0xFFFFFFFF // ALIGN * ALIGN) + if count > root.sectors: + dev = f"/dev/{name}" + fd = os.open(dev, os.O_RDWR | os.O_EXCL | os.O_CLOEXEC) + try: + mbr = bytearray(os.pread(fd, SECTOR, 0)) + struct.pack_into(" {human(count * SECTOR)}") + reread_partitions(rep, name, [p.number for p in parts]) + root.sectors = count + else: + rep.log(f"partition {root.number} already fills the card ({human(root.size)})") + fsck(rep, root.node) + blocks, bs, _ = fs_info(rep, root.node) + target = min(root.size, MAX_ROOT_BYTES) // bs + if target > blocks: + rep.log(f"growing the volume: {human(blocks * bs)} -> {human(target * bs)}") + rep.progress(0, 1, "Growing the volume (resize2fs)", force=True) + run(rep, ["resize2fs", root.node, str(target)]) + check_features(rep, root.node) + fsck(rep, root.node, check_only=True) + else: + rep.log(f"the volume already fills the partition ({human(blocks * bs)})") + + +# ==== Flash ==== + +def cmd_flash(rep, args): + img = Image(args.image) + name = check_target(rep, args.device) + dev = f"/dev/{name}" + card = disk_bytes(name) + known = img.size() + if known and known > card: + raise Fail(f"the image ({human(known)}) does not fit on the card ({human(card)})") + confirm(args, f"Everything on {dev} ({human(card)}) will be erased.") + + rep.stage(f"Preparing {dev}") + with no_automount(rep, name): + unmount_disk(rep, name) + try: + fd = os.open(dev, os.O_RDWR | os.O_EXCL | os.O_CLOEXEC) + except OSError as e: + raise Fail(f"cannot open {dev}: {e.strerror} (is something still using it?)") + # One descriptor from the first write to the reread: udev re-probes + # the disk when a writer closes it, and a volume mounted before the + # read-back would be changed under the verification. + try: + written, digest = write_image(rep, img, fd, card, dev) + if not args.no_verify: + verify_image(rep, fd, written, digest) + parts = parse_mbr(os.pread(fd, SECTOR, 0)) or [] + with contextlib.suppress(OSError): + fcntl.ioctl(fd, BLKRRPART) + finally: + os.close(fd) + reread_partitions(rep, name, [p.number for p in parts]) + if args.expand: + expand_root(rep, name) + settle() + rep.done(f"MontaukOS is on {dev}. You can remove the card.") + + +def write_image(rep, img, fd, card, dev): + digest = hashlib.sha256() + written = 0 + rep.stage(f"Writing {os.path.basename(img.path)} to {dev}") + rep.cancellable = True + try: + start = time.monotonic() + synced = 0 + with img.open() as (stream, raw): + while True: + rep.check_cancel() + buf = stream.read(CHUNK) + if not buf: + break + if written == 0: + try: + ok = parse_mbr(buf[:SECTOR]) is not None + except Fail: + ok = False + if not ok: + raise Fail(f"{os.path.basename(img.path)} is not a MontaukOS Raspberry Pi image " + "(no MBR partition table); nothing was written") + if written + len(buf) > card: + raise Fail(f"the image does not fit on the card ({human(card)})") + view = memoryview(buf) + while view: + n = os.write(fd, view) + view = view[n:] + digest.update(buf) + written += len(buf) + if written - synced >= 32 * MIB: + os.fsync(fd) # keep the progress honest: page cache is not the card + synced = written + rate = written / max(time.monotonic() - start, 0.001) + rep.progress(raw.tell(), img.file_size, + f"Writing: {written // MIB} MiB ({rate / 1e6:.1f} MB/s)") + rep.progress(1, 1, "Flushing to the card", force=True) + os.fsync(fd) + except Fail as e: + if str(e) == "cancelled": + os.fsync(fd) + raise Fail("cancelled; the card is incomplete now, write it again before using it") + raise + finally: + rep.cancellable = False + # Clear the last MiB: a backup GPT left by an earlier OS would otherwise + # outlive the MBR this image brings. + if card - written > 2 * MIB: + os.pwrite(fd, bytes(MIB), card - MIB) + os.fsync(fd) + rep.log(f"wrote {human(written)}") + return written, digest.digest() + + +def verify_image(rep, fd, written, digest): + rep.stage("Verifying") + fcntl.ioctl(fd, BLKFLSBUF) # read the card, not the page cache + os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) + check = hashlib.sha256() + done = 0 + start = time.monotonic() + while done < written: + if rep.cancel.is_set(): + rep.warn("verification cancelled; the card was written completely but not checked") + return + buf = os.pread(fd, min(CHUNK, written - done), done) + if not buf: + break + check.update(buf) + done += len(buf) + rate = done / max(time.monotonic() - start, 0.001) + rep.progress(done, written, f"Verifying: {done // MIB} MiB ({rate / 1e6:.1f} MB/s)") + if done != written or check.digest() != digest: + raise Fail("verification failed: the card does not read back what was written " + "(a failing or counterfeit card?)") + rep.log("the card reads back correctly") + + +# ==== Update ==== + +def toml_sections(text): + """[(table name or None, [lines])]: each table with the lines up to the next.""" + header = re.compile(r"^\[([A-Za-z0-9_.\-\"]+)\]\s*(#.*)?$") + secs = [(None, [])] + for line in text.splitlines(): + m = header.match(line) + if m: + secs.append((m.group(1), [line])) + else: + secs[-1][1].append(line) + return secs + + +def merge_init(new, old): + """The image's init.toml, keeping the card's on/off choices and own services.""" + enabled = re.compile(r"^\s*enabled\s*=\s*(true|false)\b") + old_secs = {n: l for n, l in toml_sections(old) if n} + new_secs = toml_sections(new) + names = {n for n, _ in new_secs} + out = [] + for n, lines in new_secs: + lines = list(lines) + if n and n.startswith("services.") and n in old_secs: + choice = next((m.group(1) for m in map(enabled.match, old_secs[n]) if m), None) + if choice is not None: + idx = [i for i, l in enumerate(lines) if enabled.match(l)] + if idx: + lines[idx[0]] = f"enabled = {choice}" + else: + lines.insert(1, f"enabled = {choice}") + out.extend(lines) + extra = [(n, l) for n, l in toml_sections(old) + if n and n.startswith("services.") and n not in names] + if extra: + out += ["", "# Services from the previous installation."] + for _, lines in extra: + out.extend(lines) + return "\n".join(out).rstrip("\n") + "\n" + + +def merge_caps(new, old): + """The image's capabilities.toml plus the grants only the card has.""" + names = {n for n, _ in toml_sections(new)} + extra = [(n, l) for n, l in toml_sections(old) + if n and n.startswith("grant.") and n not in names] + out = new.rstrip("\n").splitlines() + if extra: + out += ["", "# ==== Grants from the previous installation ====", ""] + for _, lines in extra: + out.extend(lines) + return "\n".join(out).rstrip("\n") + "\n" + + +class Op: + def __init__(self, kind, rel, dst, src=None, data=None, size=0, replaced=0): + self.kind, self.rel, self.dst, self.src, self.data = kind, rel, dst, src, data + self.size, self.replaced = size, replaced + + +def alloc(size, bs, ext): + blocks = -(-size // bs) + if ext and blocks > 12: + blocks += -(-blocks // (bs // 4)) # indirect blocks + return blocks * bs + + +class Plan: + def __init__(self, what, dst_root, ext): + self.what, self.dst_root, self.ext = what, dst_root, ext + self.ops = [] + self.unchanged = 0 + st = os.statvfs(dst_root) + self.bs = st.f_frsize + self.free = st.f_bavail * st.f_frsize + + def write(self, rel, src=None, data=None): + dst = os.path.join(self.dst_root, rel) + size = len(data) if data is not None else os.path.getsize(src) + replaced = os.path.getsize(dst) if os.path.isfile(dst) else 0 + self.ops.append(Op("write", rel, dst, src, data, size, replaced)) + + def needed(self): + a = lambda n: alloc(n, self.bs, self.ext) + net = 0 + biggest = 0 + for op in self.ops: + if op.kind == "write": + net += a(op.size) - (a(op.replaced) if op.replaced else 0) + biggest = max(biggest, a(op.size)) + elif op.kind == "backup": + net += a(op.size) + elif op.kind == "delete": + net -= a(op.size) + return net + biggest + MIB + + def count(self, kind): + return sum(1 for op in self.ops if op.kind == kind) + + +def same_file(a, b): + try: + return os.path.getsize(a) == os.path.getsize(b) and filecmp.cmp(a, b, shallow=False) + except OSError: + return False + + +def plan_tree(rep, plan, src_root, decide, prune_dirs=(), preserve=()): + shipped = set() + files = [] + for dirpath, dirnames, filenames in os.walk(src_root): + rel_dir = os.path.relpath(dirpath, src_root) + rel_dir = "" if rel_dir == "." else rel_dir + dirnames[:] = sorted(d for d in dirnames + if decide(os.path.join(rel_dir, d), True) != "skip") + for d in dirnames: + shipped.add(os.path.join(rel_dir, d)) + for fn in sorted(filenames): + files.append(os.path.join(rel_dir, fn)) + shipped.add(files[-1]) + + for i, rel in enumerate(files): + rep.progress(i, len(files), f"Comparing {plan.what} ({i}/{len(files)})") + src = os.path.join(src_root, rel) + dst = os.path.join(plan.dst_root, rel) + policy = decide(rel, False) + exists = os.path.lexists(dst) + if exists and os.path.isdir(dst): + raise Fail(f"{rel} is a directory on the card but a file in the image") + if policy == "skip" or (policy == "add" and exists): + continue + if policy == "firstboot" and not exists: + rep.log("not adding os/firstboot.elf: the card's system volume has already grown") + continue + if policy in ("merge-init", "merge-caps") and exists: + with open(src, encoding="utf-8", errors="replace") as f: + new = f.read() + with open(dst, encoding="utf-8", errors="replace") as f: + old = f.read() + merged = (merge_init if policy == "merge-init" else merge_caps)(new, old) + if merged == old: + plan.unchanged += 1 + continue + plan.ops.append(Op("backup", rel, dst, size=len(old.encode()))) + plan.write(rel, data=merged.encode()) + continue + if exists and same_file(src, dst): + plan.unchanged += 1 + continue + if policy == "backup" and exists: + plan.ops.append(Op("backup", rel, dst, size=os.path.getsize(dst))) + plan.write(rel, src=src) + + for top in prune_dirs: + base = os.path.join(plan.dst_root, top) + if not os.path.isdir(base) or top not in shipped: + continue + for dirpath, dirnames, filenames in os.walk(base, topdown=False): + rel_dir = os.path.relpath(dirpath, plan.dst_root) + for fn in filenames: + rel = os.path.join(rel_dir, fn) + if rel not in shipped and rel not in preserve: + path = os.path.join(dirpath, fn) + plan.ops.append(Op("delete", rel, path, size=os.path.getsize(path))) + if rel_dir not in shipped: + plan.ops.append(Op("rmdir", rel_dir, dirpath)) + + +def execute(rep, plan, done, total): + for op in plan.ops: + if op.kind == "delete": + rep.log(f"removing {op.rel}") + os.unlink(op.dst) + for op in plan.ops: + if op.kind == "rmdir": + with contextlib.suppress(OSError): + os.rmdir(op.dst) # only if it ended up empty + for op in plan.ops: + if op.kind == "backup": + suffix = ".bak" if op.rel in ESP_BACKUP else ".old" + rep.log(f"keeping the card's {op.rel} as {os.path.basename(op.rel)}{suffix}") + shutil.copyfile(op.dst, op.dst + suffix) + for op in plan.ops: + if op.kind != "write": + continue + os.makedirs(os.path.dirname(op.dst), exist_ok=True) + tmp = os.path.join(os.path.dirname(op.dst), ".imager-" + os.path.basename(op.dst)) + with open(tmp, "wb") as out: + if op.data is not None: + out.write(op.data) + done += len(op.data) + else: + with open(op.src, "rb") as src: + while True: + buf = src.read(CHUNK) + if not buf: + break + out.write(buf) + done += len(buf) + rep.progress(done, total, f"Writing {op.rel}") + out.flush() + os.fsync(out.fileno()) + if op.src: + with contextlib.suppress(OSError): # FAT has no modes + os.chmod(tmp, os.stat(op.src).st_mode & 0o777) + os.replace(tmp, op.dst) + rep.progress(done, total, f"Writing {op.rel}") + return done + + +@contextlib.contextmanager +def mounted(rep, workdir, label, source, fstype, opts): + mnt = os.path.join(workdir, label) + os.makedirs(mnt, exist_ok=True) + run(rep, ["mount", "-t", fstype, "-o", opts, source, mnt], quiet=True) + try: + yield mnt + finally: + os.sync() + p = subprocess.run(["umount", mnt], env=tool_env(), + stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + if p.returncode: + time.sleep(1) + subprocess.run(["umount", "-l", mnt], env=tool_env(), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def cmd_update(rep, args): + img = Image(args.image) + name = check_target(rep, args.device) + with no_automount(rep, name): + update(rep, args, img, name) + + +def update(rep, args, img, name): + dev = f"/dev/{name}" + workdir = tempfile.mkdtemp(prefix="montauk-imager-") + try: + raw = img.materialize(rep, workdir) + iparts = img.partitions(raw) + iesp = next((p for p in iparts if p.type in FAT_TYPES), None) + iroot = next((p for p in iparts if p.type == TYPE_LINUX), None) + if not iesp: + raise Fail("the image has no boot partition; is it a MontaukOS Raspberry Pi image?") + + rep.stage(f"Inspecting {dev}") + unmount_disk(rep, name) + esp, root, _ = card_layout(rep, name) + if bool(iroot) != bool(root): + have = "an ext2 system partition" if root else "the single-partition (ramdisk) layout" + want = "an ext2 system partition" if iroot else "the single-partition (ramdisk) layout" + raise Fail(f"the card has {have} but the image uses {want}; write a fresh image instead") + rep.log(f"boot partition {esp.node} ({human(esp.size)})") + if root: + rep.log(f"system partition {root.node} ({human(root.size)}, " + f"{'fills the card' if root.end + ALIGN >= disk_bytes(name) // SECTOR else 'not expanded'})") + confirm(args, f"MontaukOS on {dev} will be updated; users, files and settings are kept.") + + signal.signal(signal.SIGHUP, signal.SIG_IGN) # an update must not stop halfway + signal.signal(signal.SIGINT, signal.SIG_IGN) + if root: + rep.stage("Checking the system volume") + fsck(rep, root.node) + if args.expand: + expand_root(rep, name) + esp, root, _ = card_layout(rep, name) + + with contextlib.ExitStack() as stack: + rep.stage("Mounting") + loop = lambda p: f"ro,loop,offset={p.offset},sizelimit={p.size}" + src_esp = stack.enter_context(mounted(rep, workdir, "image-esp", raw, "vfat", loop(iesp))) + dst_esp = stack.enter_context(mounted(rep, workdir, "card-esp", esp.node, "vfat", "rw,noatime")) + plans = [] + + def esp_rule(rel, is_dir): + if is_dir: + return "system" + if rel == FIRMWARE_FILE and not args.firmware: + return "add" + return "backup" if rel in ESP_BACKUP else "system" + + rep.stage("Comparing the card with the image") + plans.append((Plan("the boot partition", dst_esp, False), src_esp, esp_rule, (), ())) + + if root: + src_root = stack.enter_context(mounted(rep, workdir, "image-root", raw, "ext2", loop(iroot))) + dst_root = stack.enter_context(mounted(rep, workdir, "card-root", root.node, "ext2", "rw,noatime")) + + def root_rule(rel, is_dir): + top = rel.split("/")[0] + if top in STATE_DIRS: + return "skip" + if is_dir: + return "system" + if rel == FIRSTBOOT: + return "firstboot" + if rel in (INIT_TOML, CAPS_TOML): + if args.keep_config: + return "add" + return "merge-init" if rel == INIT_TOML else "merge-caps" + return "add" if top in MERGE_DIRS else "system" + + plans.append((Plan("the system partition", dst_root, True), src_root, root_rule, + PRUNE_DIRS if args.prune else (), PRESERVE)) + + for plan, src, rule, prune, keep in plans: + plan_tree(rep, plan, src, rule, prune, keep) + need = plan.needed() + rep.log(f"{plan.what}: {plan.count('write')} to write, {plan.unchanged} unchanged" + + (f", {plan.count('delete')} to remove" if plan.count("delete") else "")) + if need > plan.free: + hint = ("enable Expand (--expand) or write a fresh image" + if plan.ext else "write a fresh image") + raise Fail(f"not enough space on {plan.what}: needs {human(need)}, " + f"{human(plan.free)} free; {hint}") + + total = sum(op.size for plan, *_ in plans for op in plan.ops if op.kind == "write") + if total == 0 and not any(plan.ops for plan, *_ in plans): + rep.log("the card already matches the image") + rep.stage("Updating") + done = 0 + for plan, *_ in plans: + done = execute(rep, plan, done, total) + rep.progress(1, 1, "Flushing to the card", force=True) + os.sync() + rep.stage("Unmounting") + + if root: + rep.stage("Checking the system volume") + fsck(rep, root.node, check_only=True) + check_features(rep, root.node) + settle() + rep.done(f"MontaukOS on {dev} is up to date. You can remove the card.") + finally: + remove_workdir(rep, workdir) + + +def remove_workdir(rep, workdir): + """rmtree crosses mount points: never let it into a card left mounted.""" + for entry in os.listdir(workdir): + path = os.path.join(workdir, entry) + if os.path.ismount(path): + rep.warn(f"{path} is still mounted; unmount it by hand (sudo umount {path})") + elif os.path.isdir(path): + with contextlib.suppress(OSError): + os.rmdir(path) + else: + with contextlib.suppress(OSError): + os.unlink(path) + with contextlib.suppress(OSError): + os.rmdir(workdir) + + +def cmd_expand(rep, args): + name = check_target(rep, args.device) + confirm(args, f"The system partition on /dev/{name} will be grown to fill the card.") + signal.signal(signal.SIGINT, signal.SIG_IGN) + with no_automount(rep, name): + expand_root(rep, name) + settle() + rep.done(f"The system partition on /dev/{name} fills the card. You can remove the card.") + + +def confirm(args, what): + if args.yes: + return + if not sys.stdin.isatty(): + raise Fail(f"{what} Pass --yes to go ahead.") + answer = input(f"{what} Type 'yes' to continue: ") + if answer.strip().lower() != "yes": + raise Fail("aborted") + + +# ==== Card discovery (unprivileged) ==== + +def truthy(v): + return v in (True, 1, "1", "true") + + +def list_cards(show_all=False): + """Removable disks (or all non-system disks) as dicts, for the GUI and `list`.""" + cols = "NAME,PATH,SIZE,TYPE,RM,HOTPLUG,TRAN,MODEL,VENDOR,FSTYPE,LABEL,MOUNTPOINT,RO" + try: + p = subprocess.run(["lsblk", "-J", "-b", "-o", cols], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, env=tool_env(), timeout=10) + devices = json.loads(p.stdout or "{}").get("blockdevices", []) + except (OSError, ValueError, subprocess.SubprocessError): + return [] + + def mounts(d): + out = [d.get("mountpoint")] if d.get("mountpoint") else [] + for c in d.get("children") or []: + out += mounts(c) + return out + + cards = [] + for d in devices: + name = d.get("name", "") + size = int(d.get("size") or 0) + if d.get("type") != "disk" or size == 0 or name.startswith(("zram", "loop", "ram")): + continue + if any(m in SYSTEM_MOUNTS for m in mounts(d)): + continue + removable = (truthy(d.get("rm")) or truthy(d.get("hotplug")) + or d.get("tran") in ("usb", "mmc", "sdio") or name.startswith("mmcblk")) + if not removable and not show_all: + continue + parts = [] + for c in d.get("children") or []: + if c.get("type") != "part": + continue + cname = c.get("name", "") + parts.append({ + "path": c.get("path") or f"/dev/{cname}", + "size": int(c.get("size") or 0), + "start": sysfs_int(f"/sys/block/{name}/{cname}/start") * SECTOR, + "label": c.get("label") or "", + "fstype": c.get("fstype") or "", + "mount": c.get("mountpoint") or "", + }) + model = " ".join(x.strip() for x in (d.get("vendor") or "", d.get("model") or "") if x and x.strip()) + card = {"path": d.get("path") or f"/dev/{name}", "name": name, "size": size, + "model": model or ("SD card" if name.startswith("mmcblk") else "Drive"), + "ro": truthy(d.get("ro")), "removable": removable, "parts": parts} + card.update(describe(card)) + cards.append(card) + return cards + + +def describe(card): + """What is on a card: {'kind': 'ext2'|'ramdisk'|'other'|'empty', 'contents', 'fills'}.""" + parts = card["parts"] + root = next((p for p in parts if p["label"] == ROOT_LABEL), None) + esp = next((p for p in parts if p["label"] == ESP_LABEL and p["fstype"] == "vfat"), None) + if root: + fills = (root["start"] + root["size"] + 2 * MIB >= card["size"] + or root["size"] >= MAX_ROOT_BYTES - MIB) + text = f"MontaukOS, system partition {human(root['size'])}" + text += ", fills the card" if fills else ", not expanded yet" + return {"kind": "ext2", "contents": text, "fills": fills} + if esp: + return {"kind": "ramdisk", "contents": "MontaukOS (single partition, ramdisk)", "fills": True} + if parts: + names = [p["label"] or p["fstype"] or "unknown" for p in parts] + return {"kind": "other", "contents": ", ".join(names), "fills": False} + return {"kind": "empty", "contents": "empty", "fills": False} + + +def cmd_list(rep, args): + cards = list_cards(args.all) + if not cards: + print("No SD cards or USB drives found." + ("" if args.all else " (--all shows fixed disks too)")) + for c in cards: + print(f"{c['path']:<16} {human(c['size']):>9} {c['model']:<28} {c['contents']}" + + (" [write-protected]" if c["ro"] else "")) + + +def image_summary(path): + """One line about an image file for the GUI, and its layout if it can tell.""" + try: + img = Image(path) + except Fail as e: + return str(e), None + when = time.strftime("%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(img.path))) + if img.kind != "raw": + return f"{img.kind} compressed, {human(img.file_size)}, {when}", None + try: + parts = img.partitions(img.path) + except Fail as e: + return str(e), None + layout = "ext2" if any(p.type == TYPE_LINUX for p in parts) else "ramdisk" + desc = "boot + ext2 system partition" if layout == "ext2" else "single partition (ramdisk)" + return f"{human(img.file_size)}, {desc}, built {when}", layout + + +# ==== GUI ==== + +def gui_main(initial): + try: + import tkinter as tk + from tkinter import ttk, filedialog, messagebox + except ImportError: + sys.exit("montauk-imager: the GUI needs Tkinter (sudo apt install python3-tk)") + try: + root = tk.Tk() + except tk.TclError as e: + sys.exit(f"montauk-imager: cannot open a window ({e}); use the list/flash/update commands") + + root.title("MontaukOS Imager") + root.minsize(680, 600) + style = ttk.Style(root) + if "clam" in style.theme_names(): + style.theme_use("clam") + style.configure("Title.TLabel", font=("TkDefaultFont", 15, "bold")) + style.configure("Dim.TLabel", foreground="#666666") + style.configure("Accent.TButton", font=("TkDefaultFont", 10, "bold")) + + S = {"busy": None, "proc": None, "cards": [], "sig": None, "layout": None} + events = queue.Queue() + + image_var = tk.StringVar(value=initial or (DEFAULT_IMAGE if os.path.isfile(DEFAULT_IMAGE) else "")) + image_info = tk.StringVar() + show_all = tk.BooleanVar(value=False) + action = tk.StringVar(value="flash") + verify = tk.BooleanVar(value=True) + flash_expand = tk.BooleanVar(value=False) + merge_cfg = tk.BooleanVar(value=True) + firmware = tk.BooleanVar(value=False) + prune = tk.BooleanVar(value=False) + upd_expand = tk.BooleanVar(value=False) + status = tk.StringVar(value="Choose an image and an SD card.") + card_info = tk.StringVar() + + main = ttk.Frame(root, padding=16) + main.pack(fill="both", expand=True) + main.columnconfigure(0, weight=1) + + head = ttk.Frame(main) + head.grid(row=0, column=0, sticky="ew") + ttk.Label(head, text="MontaukOS Imager", style="Title.TLabel").pack(side="left") + ttk.Label(head, text="Raspberry Pi 4 / 400", style="Dim.TLabel").pack(side="left", padx=10, pady=(5, 0)) + + # Image + imgf = ttk.LabelFrame(main, text="Image", padding=10) + imgf.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + imgf.columnconfigure(0, weight=1) + img_entry = ttk.Entry(imgf, textvariable=image_var) + img_entry.grid(row=0, column=0, sticky="ew") + browse_btn = ttk.Button(imgf, text="Browse...", command=lambda: browse()) + browse_btn.grid(row=0, column=1, padx=(6, 0)) + build_btn = ttk.Button(imgf, text="Build", command=lambda: build()) + if REPO: + build_btn.grid(row=0, column=2, padx=(6, 0)) + ttk.Label(imgf, textvariable=image_info, style="Dim.TLabel").grid(row=1, column=0, columnspan=3, sticky="w", pady=(4, 0)) + + # Card + cardf = ttk.LabelFrame(main, text="SD card", padding=10) + cardf.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) + main.rowconfigure(2, weight=1) + cardf.columnconfigure(0, weight=1) + cardf.rowconfigure(0, weight=1) + tree = ttk.Treeview(cardf, columns=("device", "size", "model", "contents"), show="headings", + height=4, selectmode="browse") + for col, text, width, anchor in (("device", "Device", 110, "w"), ("size", "Size", 80, "e"), + ("model", "Name", 170, "w"), ("contents", "Contents", 300, "w")): + tree.heading(col, text=text, anchor=anchor) + tree.column(col, width=width, anchor=anchor, stretch=(col == "contents")) + tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + ttk.Label(cardf, textvariable=card_info, style="Dim.TLabel").grid(row=1, column=0, sticky="w", pady=(4, 0)) + ttk.Checkbutton(cardf, text="Show all drives", variable=show_all, + command=lambda: refresh(force=True)).grid(row=1, column=1, padx=6, pady=(4, 0)) + ttk.Button(cardf, text="Refresh", command=lambda: refresh(force=True)).grid(row=1, column=2, pady=(4, 0)) + + # Action + actf = ttk.LabelFrame(main, text="Action", padding=10) + actf.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + actf.columnconfigure(1, weight=1) + rb_flash = ttk.Radiobutton(actf, text="Write a fresh image (erases the card)", value="flash", + variable=action, command=lambda: update_ui()) + rb_update = ttk.Radiobutton(actf, text="Update the installation on the card (keeps users, files and settings)", + value="update", variable=action, command=lambda: update_ui()) + rb_flash.grid(row=0, column=0, columnspan=2, sticky="w") + rb_update.grid(row=1, column=0, columnspan=2, sticky="w") + + optf = ttk.Frame(actf, padding=(22, 4, 0, 0)) + optf.grid(row=2, column=0, columnspan=2, sticky="ew") + flash_opts = [ + ttk.Checkbutton(optf, text="Verify the card after writing", variable=verify), + ttk.Checkbutton(optf, text="Expand the system partition to fill the card now " + "(otherwise MontaukOS does it on first boot)", variable=flash_expand), + ] + update_opts = [ + ttk.Checkbutton(optf, text="Merge new services and capability grants into the card's configuration", + variable=merge_cfg), + ttk.Checkbutton(optf, text="Update the UEFI firmware (RPI_EFI.fd; resets the firmware's settings)", + variable=firmware), + ttk.Checkbutton(optf, text="Remove system files the new image no longer ships", variable=prune), + ttk.Checkbutton(optf, text="Expand the system partition to fill the card", variable=upd_expand), + ] + + # Progress + progf = ttk.Frame(main) + progf.grid(row=4, column=0, sticky="ew", pady=(12, 0)) + progf.columnconfigure(0, weight=1) + bar = ttk.Progressbar(progf, mode="determinate", maximum=1000) + bar.grid(row=0, column=0, columnspan=3, sticky="ew") + ttk.Label(progf, textvariable=status).grid(row=1, column=0, sticky="w", pady=(4, 0)) + log_btn = ttk.Button(progf, text="Show log", command=lambda: toggle_log()) + log_btn.grid(row=1, column=1, padx=6, pady=(4, 0)) + logf = ttk.Frame(main) + logf.columnconfigure(0, weight=1) + logf.rowconfigure(0, weight=1) + log = tk.Text(logf, height=10, wrap="word", state="disabled", font=("TkFixedFont", 9), + relief="flat", borderwidth=1) + scroll = ttk.Scrollbar(logf, orient="vertical", command=log.yview) + log.configure(yscrollcommand=scroll.set) + log.grid(row=0, column=0, sticky="nsew") + scroll.grid(row=0, column=1, sticky="ns") + log.tag_configure("stage", font=("TkFixedFont", 9, "bold")) + log.tag_configure("warn", foreground="#a05a00") + log.tag_configure("error", foreground="#b00020", font=("TkFixedFont", 9, "bold")) + log.tag_configure("done", foreground="#0a7a2f", font=("TkFixedFont", 9, "bold")) + + btns = ttk.Frame(main) + btns.grid(row=6, column=0, sticky="e", pady=(12, 0)) + cancel_btn = ttk.Button(btns, text="Cancel", command=lambda: cancel()) + start_btn = ttk.Button(btns, text="Write", style="Accent.TButton", command=lambda: start()) + cancel_btn.pack(side="left", padx=(0, 6)) + start_btn.pack(side="left") + + def toggle_log(show=None): + visible = bool(logf.winfo_ismapped()) + show = (not visible) if show is None else show + if show and not visible: + logf.grid(row=5, column=0, sticky="nsew", pady=(8, 0)) + main.rowconfigure(5, weight=2) + log_btn.configure(text="Hide log") + elif not show and visible: + logf.grid_remove() + main.rowconfigure(5, weight=0) + log_btn.configure(text="Show log") + + def append(text, tag=None): + log.configure(state="normal") + log.insert("end", text + "\n", (tag,) if tag else ()) + log.see("end") + log.configure(state="disabled") + + def selected(): + sel = tree.selection() + return next((c for c in S["cards"] if c["path"] == (sel[0] if sel else None)), None) + + def refresh(force=False): + if S["busy"] in ("flash", "update", "expand") and not force: + return + cards = list_cards(show_all.get()) + sig = json.dumps(cards, sort_keys=True) + if sig == S["sig"] and not force: + return + S["sig"] = sig + keep = selected() + S["cards"] = cards + tree.delete(*tree.get_children()) + for c in cards: + contents = c["contents"] + (" [write-protected]" if c["ro"] else "") + tree.insert("", "end", iid=c["path"], + values=(c["path"], human(c["size"]), c["model"], contents)) + if keep and tree.exists(keep["path"]): + tree.selection_set(keep["path"]) + elif len(cards) == 1: + tree.selection_set(cards[0]["path"]) + update_ui() + + def poll_cards(): + if not S["busy"]: + refresh() + root.after(2000, poll_cards) + + def refresh_image(*_): + path = image_var.get().strip() + if not path: + image_info.set("No image chosen." + (" Build one, or browse for montauk-rpi4.img." if REPO else "")) + S["layout"] = None + else: + info, S["layout"] = image_summary(path) + image_info.set(info) + update_ui() + + def update_ui(*_): + card = selected() + installed = bool(card and card["kind"] in ("ext2", "ramdisk")) + rb_update.configure(state="normal" if installed and not S["busy"] else "disabled") + if action.get() == "update" and not installed: + action.set("flash") + for w in flash_opts + update_opts: + w.pack_forget() + opts = flash_opts if action.get() == "flash" else update_opts + for w in opts: + w.pack(anchor="w") + if card: + if card["kind"] == "ext2" and card["fills"]: + upd_expand.set(False) + card_info.set(f"{card['path']}: {card['model']}, {human(card['size'])}, {card['contents']}") + else: + card_info.set("Insert an SD card (directly or in a USB reader)." if not S["cards"] + else "Select the card to write.") + state = "disabled" if S["busy"] else "normal" + for w in opts + [img_entry, browse_btn, build_btn, rb_flash]: + w.configure(state=state) + if card and card["kind"] == "ext2" and card["fills"] and not S["busy"]: + update_opts[3].configure(state="disabled") + if card and card["kind"] == "ramdisk": + update_opts[3].configure(state="disabled") + start_btn.configure(text="Write" if action.get() == "flash" else "Update") + ready = card and image_var.get().strip() and not S["busy"] and not card["ro"] + start_btn.configure(state="normal" if ready else "disabled") + cancel_btn.configure(state="normal" if S["busy"] in ("flash", "build") else "disabled") + + def browse(): + start_dir = os.path.dirname(image_var.get()) or REPO or os.path.expanduser("~") + path = filedialog.askopenfilename( + parent=root, title="Choose a MontaukOS image", initialdir=start_dir, + filetypes=[("Disk images", "*.img *.img.gz *.img.xz *.gz *.xz *.zip"), ("All files", "*")]) + if path: + image_var.set(path) + + def launch(kind, cmd, cwd=None): + S["busy"] = kind + bar.configure(mode="determinate", value=0) + append("$ " + " ".join(cmd), "stage") + try: + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1, + errors="replace", cwd=cwd) + except OSError as e: + S["busy"] = None + fail(f"Could not start {cmd[0]}: {e}") + return + S["proc"] = proc + + def reader(): + for line in proc.stdout: + events.put(("line", line.rstrip("\n"))) + events.put(("exit", proc.wait())) + threading.Thread(target=reader, daemon=True).start() + update_ui() + + def fail(msg): + status.set(msg) + append(msg, "error") + toggle_log(True) + messagebox.showerror("MontaukOS Imager", msg, parent=root) + + def pump(): + try: + while True: + kind, val = events.get_nowait() + if kind == "line": + handle_line(val) + else: + finished(val) + except queue.Empty: + pass + root.after(50, pump) + + def handle_line(line): + if S["busy"] == "build" or not line.startswith("@"): + append(line) + if S["busy"] == "build" and line.strip(): + status.set(line.strip()[:90]) + return + tag, _, text = line[1:].partition(" ") + if tag == "progress": + done, total, text = (text.split(" ", 2) + ["", ""])[:3] + bar.configure(mode="determinate", value=1000 * int(done) / max(int(total), 1)) + status.set(text) + elif tag in ("stage", "log", "warn", "error", "done"): + append(("==> " + text) if tag == "stage" else text, tag if tag != "log" else None) + if tag in ("stage", "done"): + status.set(text) + if tag == "error": + S["error"] = text + if tag == "warn": + toggle_log(True) + + def finished(rc): + kind = S["busy"] + S["busy"], S["proc"] = None, None + if kind == "build": + bar.stop() + bar.configure(mode="determinate", value=0) + if rc == 0 and os.path.isfile(DEFAULT_IMAGE): + status.set("Image built.") + image_var.set(DEFAULT_IMAGE) + refresh_image() + else: + fail(f"The build failed (make exit {rc}); see the log.") + elif rc == 0: + bar.configure(value=1000) + messagebox.showinfo("MontaukOS Imager", status.get(), parent=root) + else: + err = S.pop("error", None) + if rc == 126 and not err: + status.set("Authorization was cancelled.") + elif rc == 127 and not err: + fail("Could not get administrator rights through pkexec. " + "Run the imager with sudo instead.") + else: + fail(err or f"Failed (exit {rc}); see the log.") + S["sig"] = None + refresh(force=True) + + def privileged(args): + base = [sys.executable, "-I", SCRIPT, "--machine"] + args + ["--yes"] + if os.geteuid() == 0: + return base + if not shutil.which("pkexec"): + return None + return ["pkexec"] + base + + def start(): + card = selected() + path = os.path.abspath(image_var.get().strip()) + if not card: + return + if not os.path.isfile(path): + fail(f"The image {path} does not exist.") + return + what = f"{card['model']} ({card['path']}, {human(card['size'])})" + if action.get() == "flash": + if card["kind"] != "empty": + detail = f"It currently holds: {card['contents']}.\n\n" + else: + detail = "" + if not messagebox.askyesno( + "Erase the card?", + f"Everything on {what} will be erased and replaced with MontaukOS.\n\n{detail}" + "Continue?", icon="warning", default="no", parent=root): + return + args = ["flash", path, card["path"]] + if not verify.get(): + args.append("--no-verify") + if flash_expand.get(): + args.append("--expand") + else: + if S["layout"] and card["kind"] != S["layout"]: + fail("The card and the image use different layouts " + "(ext2 system partition vs single ramdisk partition). Write a fresh image instead.") + return + if not messagebox.askyesno( + "Update MontaukOS?", + f"MontaukOS on {what} will be updated from\n{os.path.basename(path)}.\n\n" + "Users, their files and settings are kept. Do not remove the card " + "until the update has finished.\n\nContinue?", parent=root): + return + args = ["update", path, card["path"]] + if not merge_cfg.get(): + args.append("--keep-config") + if firmware.get(): + args.append("--firmware") + if prune.get(): + args.append("--prune") + if upd_expand.get(): + args.append("--expand") + cmd = privileged(args) + if not cmd: + fail("Writing a card needs administrator rights, but pkexec is not installed. " + "Run the imager with sudo.") + return + S.pop("error", None) + status.set("Waiting for authorization..." if cmd[0] == "pkexec" else "Starting...") + launch(args[0], cmd) + + def build(): + if not REPO: + return + status.set("Building the image (make ARCH=aarch64 rpi4-sd)...") + toggle_log(True) + launch("build", ["make", "ARCH=aarch64", "rpi4-sd"], cwd=REPO) + bar.configure(mode="indeterminate") + bar.start(15) + + def cancel(): + proc = S["proc"] + if not proc: + return + if S["busy"] == "build": + proc.terminate() + bar.stop() + return + if S["busy"] == "flash" and messagebox.askyesno( + "Cancel writing?", "Stop writing the card? It will not boot until it is written again.", + parent=root): + with contextlib.suppress(OSError): + proc.stdin.write("cancel\n") + proc.stdin.flush() + status.set("Cancelling...") + + def on_close(): + if S["busy"] == "update" or S["busy"] == "expand": + messagebox.showwarning("MontaukOS Imager", + "The card is being updated. Wait until it has finished.", parent=root) + return + if S["busy"] == "flash": + if not messagebox.askyesno("Quit?", "The card is still being written. Stop and quit?", + parent=root): + return + with contextlib.suppress(OSError): + S["proc"].stdin.write("cancel\n") + S["proc"].stdin.flush() + if S["busy"] == "build": + S["proc"].terminate() + root.destroy() + + tree.bind("<>", update_ui) + image_var.trace_add("write", refresh_image) + root.protocol("WM_DELETE_WINDOW", on_close) + refresh_image() + refresh(force=True) + root.after(50, pump) + root.after(2000, poll_cards) + root.mainloop() + + +# ==== Entry ==== + +def main(): + ap = argparse.ArgumentParser(description="Write and update MontaukOS SD cards for the Raspberry Pi 4/400.") + ap.add_argument("--machine", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--image", help="image to preselect in the GUI") + sub = ap.add_subparsers(dest="cmd") + p = sub.add_parser("list", help="list SD cards and USB drives") + p.add_argument("--all", action="store_true", help="include fixed disks") + p = sub.add_parser("flash", help="write an image over the whole card") + p.add_argument("image") + p.add_argument("device") + p.add_argument("--no-verify", action="store_true", help="do not read the card back") + p.add_argument("--expand", action="store_true", help="grow the system partition to fill the card") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + p = sub.add_parser("update", help="update the installation on a card, keeping user data") + p.add_argument("image") + p.add_argument("device") + p.add_argument("--keep-config", action="store_true", help="leave init.toml and capabilities.toml alone") + p.add_argument("--firmware", action="store_true", help="also replace RPI_EFI.fd (resets UEFI settings)") + p.add_argument("--prune", action="store_true", help="delete system files the image no longer ships") + p.add_argument("--expand", action="store_true", help="grow the system partition to fill the card first") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + p = sub.add_parser("expand", help="grow the system partition to fill the card") + p.add_argument("device") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + args = ap.parse_args() + + if args.cmd is None: + gui_main(args.image) + return + rep = Reporter(args.machine) + if args.cmd == "list": + cmd_list(rep, args) + return + if os.geteuid() != 0: + rep.error("writing a card needs root: run with sudo (the GUI asks through pkexec)") + sys.exit(1) + if args.machine: + watch_stdin(rep) + try: + {"flash": cmd_flash, "update": cmd_update, "expand": cmd_expand}[args.cmd](rep, args) + except Fail as e: + rep.error(str(e)) + sys.exit(1) + except KeyboardInterrupt: + rep.error("interrupted") + sys.exit(130) + except OSError as e: + rep.error(f"{e.filename + ': ' if e.filename else ''}{e.strerror or e}") + sys.exit(1) + + +if __name__ == "__main__": + main()