diff --git a/AGENTS.md b/AGENTS.md index 78eb727..98101af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,15 @@ nixos/ ├── flake.nix # Flake inputs and outputs ├── configuration.nix # Main config (imports all modules) ├── hardware-configuration.nix # Auto-generated (don't edit) +├── overlays/ # Custom nixpkgs overlays +│ └── limine-install-patched.py # Patched Limine install script └── modules/ ├── apps.nix # User applications ├── desktop.nix # Wayland, portals, polkit ├── dev.nix # Docker, dev tools, languages ├── gaming.nix # Steam, Gamemode, Wine ├── gpu-amd.nix # AMD GPU drivers + ├── limine-custom-labels.nix # Custom boot entry labels ├── shell.nix # Fish shell config ├── theming.nix # Fonts, themes, cursors └── ... # Other modules @@ -164,3 +167,33 @@ nix profile install nixpkgs#packagename - NixOS fails builds on errors - this is the primary validation - Always test with `nixos-rebuild test` before `switch` - Use `nix flake check` for quick syntax validation + +## Custom Limine Boot Labels (MAINTENANCE REQUIRED) + +**Files:** +- `modules/limine-custom-labels.nix` - Module that applies the patch +- `overlays/limine-install-patched.py` - Patched install script + +**What it does:** +- Changes boot entries from "Generation XYZ" to "Linux 6.X.Y-cachyos - Generation XYZ" +- Removes "default profile" from group name (shows just "NixOS") +- Shows kernel version in all entries including specialisations + +**How it works:** +The module imports the standard Limine module but overrides `system.build.installBootLoader` +with a patched Python script that extracts kernel version from the kernel path. + +**Maintenance burden:** +- **HIGH** - This will break when nixpkgs updates the limine module +- Check after every `nix flake update` by running `nixos-rebuild test` +- If it breaks, compare `overlays/limine-install-patched.py` with the upstream + `nixos/modules/system/boot/loader/limine/limine-install.py` in nixpkgs +- Typical breakage: line number changes, function signature changes, new bootspec fields + +**To fix breakage:** +1. Check current upstream script: https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/system/boot/loader/limine/limine-install.py +2. Identify the changes needed +3. Re-apply the two key modifications: + - Line ~550: Change `group_name = 'default profile'...` to `group_name = ''` + - Add `get_kernel_version()` function and use it in `generate_config_entry()` +4. Test with `sudo nixos-rebuild test --flake .#nixos` diff --git a/configuration.nix b/configuration.nix index c574d16..2b27757 100644 --- a/configuration.nix +++ b/configuration.nix @@ -27,6 +27,7 @@ ./modules/shell.nix # Fish shell configuration ./modules/services.nix # System services (fstrim, zram, avahi, psd) ./modules/navidrome.nix # Music streaming server + ./modules/limine-custom-labels.nix # Custom boot entry labels with kernel version ]; # ═══════════════════════════════════════════════════════════════ diff --git a/modules/limine-custom-labels.nix b/modules/limine-custom-labels.nix new file mode 100644 index 0000000..9fc8716 --- /dev/null +++ b/modules/limine-custom-labels.nix @@ -0,0 +1,63 @@ +# modules/limine-custom-labels.nix +# Custom Limine bootloader module with modified entry labels +# Shows kernel version in boot entries: "Linux X.Y.Z-cachyos - Generation N" +# Removes "default profile" from group name + +{ + config, + pkgs, + lib, + ... +}: + +let + cfg = config.boot.loader.limine; + efi = config.boot.loader.efi; + + # Patched install script that shows kernel version in labels + limineInstallPatched = pkgs.replaceVarsWith { + src = ../overlays/limine-install-patched.py; + isExecutable = true; + replacements = { + python3 = pkgs.python3.withPackages (python-packages: [ python-packages.psutil ]); + configPath = pkgs.writeText "limine-install.json" ( + builtins.toJSON { + nixPath = config.nix.package; + efiBootMgrPath = pkgs.efibootmgr; + liminePath = cfg.package; + efiMountPoint = efi.efiSysMountPoint; + fileSystems = config.fileSystems; + luksDevices = builtins.attrNames config.boot.initrd.luks.devices; + canTouchEfiVariables = efi.canTouchEfiVariables; + efiSupport = cfg.efiSupport; + efiRemovable = cfg.efiInstallAsRemovable; + secureBoot = cfg.secureBoot; + biosSupport = cfg.biosSupport; + biosDevice = cfg.biosDevice; + partitionIndex = cfg.partitionIndex; + force = cfg.force; + enrollConfig = cfg.enrollConfig; + style = cfg.style; + resolution = cfg.resolution; + maxGenerations = if cfg.maxGenerations == null then 0 else cfg.maxGenerations; + hostArchitecture = pkgs.stdenv.hostPlatform.parsed.cpu; + timeout = if config.boot.loader.timeout != null then config.boot.loader.timeout else 10; + enableEditor = cfg.enableEditor; + extraConfig = cfg.extraConfig; + extraEntries = cfg.extraEntries; + additionalFiles = cfg.additionalFiles; + validateChecksums = cfg.validateChecksums; + panicOnChecksumMismatch = cfg.panicOnChecksumMismatch; + } + ); + }; + }; +in + +{ + # Only override the installBootLoader when limine is enabled + config = lib.mkIf cfg.enable { + # Override the install script with our patched version + system.build.installBootLoader = lib.mkForce limineInstallPatched; + }; +} diff --git a/overlays/limine-install-patched.py b/overlays/limine-install-patched.py new file mode 100644 index 0000000..315bc3a --- /dev/null +++ b/overlays/limine-install-patched.py @@ -0,0 +1,710 @@ +#!@python3@/bin/python3 -B + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple + +import datetime +import hashlib +import json +from ctypes import CDLL +import os +import psutil +import re +import shutil +import subprocess +import sys +import tempfile +import textwrap + +@dataclass +class XenBootSpec: + """Represent the bootspec extension for Xen dom0 kernels""" + + efiPath: str + multibootPath: str + params: List[str] + version: str + +@dataclass +class BootSpec: + system: str + init: str + kernel: str + kernelParams: List[str] + label: str + toplevel: str + specialisations: Dict[str, "BootSpec"] + xen: XenBootSpec | None + initrd: str | None = None + initrdSecrets: str | None = None + +install_config = json.load(open('@configPath@', 'r')) +libc = CDLL("libc.so.6") + +limine_install_dir: Optional[str] = None +can_use_direct_paths = False +paths: Dict[str, bool] = {} + + +def config(*path: str) -> Optional[Any]: + result = install_config + for component in path: + result = result[component] + return result + + +def bool_to_yes_no(value: bool) -> str: + return 'yes' if value else 'no' + + +def get_system_path(profile: str = 'system', gen: Optional[str] = None, spec: Optional[str] = None) -> str: + basename = f'{profile}-{gen}-link' if gen is not None else profile + profiles_dir = '/nix/var/nix/profiles' + if profile == 'system': + result = os.path.join(profiles_dir, basename) + else: + result = os.path.join(profiles_dir, 'system-profiles', basename) + + if spec is not None: + result = os.path.join(result, 'specialisation', spec) + + return result + + +def get_profiles() -> List[str]: + profiles_dir = '/nix/var/nix/profiles/system-profiles/' + dirs = os.listdir(profiles_dir) if os.path.isdir(profiles_dir) else [] + + return [path for path in dirs if not path.endswith('-link')] + + +def get_gens(profile: str = 'system') -> List[Tuple[int, List[str]]]: + nix_env = os.path.join(str(config('nixPath')), 'bin', 'nix-env') + output = subprocess.check_output([ + nix_env, '--list-generations', + '-p', get_system_path(profile), + '--option', 'build-users-group', '', + ], universal_newlines=True) + + gen_lines = output.splitlines() + gen_nums = [int(line.split()[0]) for line in gen_lines] + + return [gen for gen in gen_nums][-config('maxGenerations'):] + + +def is_encrypted(device: str) -> bool: + for name in config('luksDevices'): + if os.readlink(os.path.join('/dev/mapper', name)) == os.readlink(device): + return True + + return False + + +def is_fs_type_supported(fs_type: str) -> bool: + return fs_type.startswith('vfat') + + +def get_dest_file(path: str) -> str: + package_id = os.path.basename(os.path.dirname(path)) + suffix = os.path.basename(path) + return f'{package_id}-{suffix}' + + +def get_dest_path(path: str, target: str) -> str: + dest_file = get_dest_file(path) + return os.path.join(str(limine_install_dir), target, dest_file) + + +def get_copied_path_uri(path: str, target: str) -> str: + result = '' + + dest_file = get_dest_file(path) + dest_path = get_dest_path(path, target) + + if not os.path.exists(dest_path): + copy_file(path, dest_path) + else: + paths[dest_path] = True + + path_with_prefix = os.path.join('/limine', target, dest_file) + result = f'boot():{path_with_prefix}' + + if config('validateChecksums'): + with open(path, 'rb') as file: + b2sum = hashlib.blake2b() + b2sum.update(file.read()) + + result += f'#{b2sum.hexdigest()}' + + return result + + +def get_path_uri(path: str) -> str: + return get_copied_path_uri(path, "") + + +def get_file_uri(profile: str, gen: Optional[str], spec: Optional[str], name: str) -> str: + gen_path = get_system_path(profile, gen, spec) + path_in_store = os.path.realpath(os.path.join(gen_path, name)) + return get_path_uri(path_in_store) + + +def get_kernel_uri(kernel_path: str) -> str: + return get_copied_path_uri(kernel_path, "kernels") + +def bootjson_to_bootspec(bootjson: dict) -> BootSpec: + specialisations = bootjson['org.nixos.specialisation.v1'] + specialisations = {k: bootjson_to_bootspec(v) for k, v in specialisations.items()} + xen = None + if 'org.xenproject.bootspec.v2' in bootjson: + xen = bootjson['org.xenproject.bootspec.v2'] + return BootSpec( + **bootjson['org.nixos.bootspec.v1'], + specialisations=specialisations, + xen=xen, + ) + +def generate_xen_efi_files( + bootspec: BootSpec, + gen: str + ) -> str: + """Generate a Xen EFI xen.cfg file, and copy required files in place. + + Assumes the bootspec has already been validated as having the requried + Xen keys. + + Arguments: + bootspec -- the NixOS BootSpec requiring Xen EFI configuration + gen -- The system generation requiring Xen EFI configuration + + Returns the path to the Xen EFI binary + """ + + xen_efi_boot_path = get_copied_path_uri(bootspec.xen['efiPath'], f'xen/{gen}') + xen_efi_path = get_dest_path(bootspec.xen['efiPath'], f'xen/{gen}') + + xen_efi_cfg_dir = os.path.dirname(xen_efi_path) + xen_efi_cfg_path = xen_efi_path[:-4] + '.cfg' + + if not os.path.exists(xen_efi_cfg_dir): + os.makedirs(xen_efi_cfg_dir) + + xen_efi_cfg = ( + f'default=nixos{gen}\n\n' + + f'[nixos{gen}]\n' + ) + # set xen dom0 parameters + if 'params' in bootspec.xen and len(bootspec.xen['params']) > 0: + xen_efi_cfg += 'options=' + ' '.join(bootspec.xen['params']).strip() + '\n' + + # set kernel and copy in-place + xen_efi_kernel_path = get_dest_path(bootspec.kernel, f'xen/{gen}') + copy_file(bootspec.kernel, xen_efi_kernel_path) + xen_efi_cfg += ( + 'kernel=' + os.path.basename(xen_efi_kernel_path) + ' ' + + ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip() + + '\n' + ) + + # set ramdisk and copy initrd in-place + if bootspec.initrd: + xen_efi_initrd_path = get_dest_path(bootspec.initrd, f'xen/{gen}') + copy_file(bootspec.initrd, xen_efi_initrd_path) + xen_efi_cfg += 'ramdisk=' + os.path.basename(xen_efi_initrd_path) + '\n' + + with open(xen_efi_cfg_path, 'w') as xen_efi_cfg_file: + xen_efi_cfg_file.write(xen_efi_cfg) + + return xen_efi_boot_path + +def xen_config_entry( + levels: int, bootspec: BootSpec, xenVersion: str, gen: str, time: str, efi: bool +) -> str: + """Generate EFI and BIOS entries for Xen dom0 kernels. + + Arguments: + levels -- The number of Limine menu levels for entries + bootspec -- The NixOS BootSpec used for generating this Limine configuration + xenVersion -- The version of Xen the entry is generated for, from the boot extension + gen -- The system generation these entries are generated for + time -- The build time for the configuration + efi -- True if EFI protocol should be used for this entry + """ + # generate Xen menu label for the current generation + entry = '/' * levels + f'Generation {gen} with Xen {xenVersion}' + (' EFI\n' if efi else '\n') + entry += f'comment: Xen {xenVersion} {bootspec.label}, built on {time}\n' + # load Xen dom0 as the executable, using multiboot for EFI & BIOS + if ( + efi and + 'multibootPath' in bootspec.xen and + len(bootspec.xen['multibootPath']) > 0 and + os.path.exists(bootspec.xen['multibootPath']) + ): + # Use the EFI protocol and generate Xen EFI configuration + # files and directories which are loaded by Xen's EFI binary + # directly. + # Ideally both EFI and BIOS booting would use multiboot2, + # however Limine's multiboot2 module has trouble finding + # an entry-point in Xen's multiboot binary, and multiboot1 + # doesn't work under EFI. + # Upstream Limine issue #482 + entry += 'protocol: efi\n' + entry += ( + 'path: ' + generate_xen_efi_files(bootspec, gen) + '\n' + ) + elif ( + 'multibootPath' in bootspec.xen and + len(bootspec.xen['multibootPath']) > 0 and + os.path.exists(bootspec.xen['multibootPath']) + ): + # Use multiboot1 if not generating an EFI entry, as multiboot2 + # doesn't work under Limine for booting Xen. + # Upstream Limine issue #483 + entry += 'protocol: multiboot\n' + entry += ( + 'path: ' + get_copied_path_uri(bootspec.xen['multibootPath'], f'xen/{gen}') + '\n' + ) + # set params as the multiboot executable's parameters + if 'params' in bootspec.xen and len(bootspec.xen['params']) > 0: + # TODO: Understand why the first argument is ignored below? + # --- to work around first argument being ignored + entry += ( + 'cmdline: -- ' + ' '.join(bootspec.xen['params']).strip() + '\n' + ) + # load the linux kernel as the second module + entry += 'module_path: ' + get_kernel_uri(bootspec.kernel) + '\n' + # set kernel parameters as the parameters to the first module + # TODO: Understand why the first argument is ignored below? + # --- to work around first argument being ignored + entry += ( + 'module_string: -- ' + + ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip() + + '\n' + ) + if bootspec.initrd: + # the final module is the initrd + entry += 'module_path: ' + get_kernel_uri(bootspec.initrd) + '\n' + return entry + +def config_entry(levels: int, bootspec: BootSpec, label: str, time: str) -> str: + entry = '/' * levels + label + '\n' + entry += 'protocol: linux\n' + entry += f'comment: {bootspec.label}, built on {time}\n' + entry += 'kernel_path: ' + get_kernel_uri(bootspec.kernel) + '\n' + entry += 'cmdline: ' + ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip() + '\n' + + # Set framebuffer resolution for Linux boot entries if configured + resolution = config('resolution') + if resolution is not None: + entry += f'resolution: {resolution}\n' + + if bootspec.initrd: + entry += f'module_path: ' + get_kernel_uri(bootspec.initrd) + '\n' + + if bootspec.initrdSecrets: + base_path = str(limine_install_dir) + '/kernels/' + initrd_secrets_path = base_path + os.path.basename(bootspec.toplevel) + '-secrets' + if not os.path.exists(base_path): + os.makedirs(base_path) + + old_umask = os.umask(0o137) + initrd_secrets_path_temp = tempfile.mktemp(os.path.basename(bootspec.toplevel) + '-secrets') + + if os.system(bootspec.initrdSecrets + " " + initrd_secrets_path_temp) != 0: + print(f'warning: failed to create initrd secrets for "{label}"', file=sys.stderr) + print(f'note: if this is an older generation there is nothing to worry about') + + if os.path.exists(initrd_secrets_path_temp): + copy_file(initrd_secrets_path_temp, initrd_secrets_path) + os.unlink(initrd_secrets_path_temp) + entry += 'module_path: ' + get_kernel_uri(initrd_secrets_path) + '\n' + + os.umask(old_umask) + return entry + + +def get_kernel_version(kernel_path: str) -> str: + """Extract kernel version from the kernel path. + + Path format: /boot/limine/kernels/{hash}-linux-{name}-{version}/vmlinuz + Example: zsrdzfxhayb0lbhcq2hwa3shik9rda20-linux-cachyos-latest-x86_64-v3-6.18.8 + Returns: 6.18.8-cachyos + """ + try: + # Get the parent directory of the kernel file (the copied kernel directory) + kernel_dir = os.path.dirname(kernel_path) + # Get the directory name which contains the store hash + package name + version + dir_name = os.path.basename(kernel_dir) + + # Extract version number from the end (e.g., 6.18.8) + version_match = re.search(r'(\d+\.\d+\.\d+)$', dir_name) + if version_match: + version = version_match.group(1) + # Check if this is a cachyos kernel by looking for "cachyos" in the name + if 'cachyos' in dir_name.lower(): + return f"{version}-cachyos" + return version + + # Fallback: if no match, return "unknown" + return "unknown" + except: + return "unknown" + + +def generate_config_entry(profile: str, gen: str, special: bool) -> str: + time = datetime.datetime.fromtimestamp(os.stat(get_system_path(profile,gen), follow_symlinks=False).st_mtime).strftime("%F %H:%M:%S") + boot_json = json.load(open(os.path.join(get_system_path(profile, gen), 'boot.json'), 'r')) + boot_spec = bootjson_to_bootspec(boot_json) + + specialisation_list = boot_spec.specialisations.items() + depth = 2 + entry = "" + + # Get kernel version for this generation + kernel_version = get_kernel_version(boot_spec.kernel) + + # Xen, if configured, should be listed first for each generation + if boot_spec.xen and 'version' in boot_spec.xen: + xen_version = boot_spec.xen['version'] + if config('efiSupport'): + entry += xen_config_entry(2, boot_spec, xen_version, gen, time, True) + entry += xen_config_entry(2, boot_spec, xen_version, gen, time, False) + + if len(specialisation_list) > 0: + depth += 1 + entry += '/' * (depth-1) + + if special: + entry += '+' + + entry += f'Linux {kernel_version} - Generation {gen}' + '\n' + entry += config_entry(depth, boot_spec, f'Linux {kernel_version} - Default', str(time)) + else: + entry += config_entry(depth, boot_spec, f'Linux {kernel_version} - Generation {gen}', str(time)) + + for spec, spec_boot_spec in specialisation_list: + spec_kernel_version = get_kernel_version(spec_boot_spec.kernel) + entry += config_entry(depth, spec_boot_spec, f'Linux {spec_kernel_version} - {spec}', str(time)) + + return entry + + +def find_disk_device(part: str) -> str: + part = os.path.realpath(part) + part = part.removeprefix('/dev/') + disk = os.path.realpath(os.path.join('/sys', 'class', 'block', part)) + disk = os.path.dirname(disk) + + return os.path.join('/dev', os.path.basename(disk)) + + +def find_mounted_device(path: str) -> str: + path = os.path.abspath(path) + + while not os.path.ismount(path): + path = os.path.dirname(path) + + devices = [x for x in psutil.disk_partitions() if x.mountpoint == path] + + assert len(devices) == 1 + return devices[0].device + + +def copy_file(from_path: str, to_path: str): + dirname = os.path.dirname(to_path) + + if not os.path.exists(dirname): + os.makedirs(dirname) + + shutil.copyfile(from_path, to_path + ".tmp") + os.rename(to_path + ".tmp", to_path) + + paths[to_path] = True + + +def option_from_config(name: str, config_path: List[str]) -> str: + value = config(*config_path) + if value is None: + return "" + if isinstance(value, bool): + value = bool_to_yes_no(value) + return f"{name}: {config(*config_path)}\n" + + +def install_bootloader() -> None: + global limine_install_dir + + boot_fs = None + + for mount_point, fs in config('fileSystems').items(): + if mount_point == '/boot': + boot_fs = fs + + if config('efiSupport'): + limine_install_dir = os.path.join(str(config('efiMountPoint')), 'limine') + elif boot_fs and is_fs_type_supported(boot_fs['fsType']) and not is_encrypted(boot_fs['device']): + limine_install_dir = '/boot/limine' + else: + possible_causes = [] + if not boot_fs: + possible_causes.append(f'/limine on the boot partition (not present)') + else: + is_boot_fs_type_ok = is_fs_type_supported(boot_fs['fsType']) + is_boot_fs_encrypted = is_encrypted(boot_fs['device']) + possible_causes.append(f'/limine on the boot partition ({is_boot_fs_type_ok=} {is_boot_fs_encrypted=}') + + causes_str = textwrap.indent('\n'.join(possible_causes), ' - ') + + raise Exception(textwrap.dedent(''' + Could not find a valid place for Limine configuration files!' + Possible candidates that were ruled out: + ''') + causes_str + textwrap.dedent(''' + Limine cannot be installed on a system without an unencrypted + partition formatted as FAT. + ''')) + + if config('secureBoot', 'enable') and not config('secureBoot', 'createAndEnrollKeys') and not os.path.exists("/var/lib/sbctl"): + print("There are no sbctl secure boot keys present. Please generate some.") + sys.exit(1) + + if not os.path.exists(limine_install_dir): + os.makedirs(limine_install_dir) + else: + for dir, dirs, files in os.walk(limine_install_dir, topdown=True): + for file in files: + paths[os.path.join(dir, file)] = False + + limine_xen_dir = os.path.join(limine_install_dir, 'xen') + if os.path.exists(limine_xen_dir): + print(f'cleaning {limine_xen_dir}') + shutil.rmtree(limine_xen_dir) + + os.makedirs(limine_xen_dir) + + profiles = [('system', get_gens())] + + for profile in get_profiles(): + profiles += [(profile, get_gens(profile))] + + timeout = config('timeout') + editor_enabled = bool_to_yes_no(config('enableEditor')) + hash_mismatch_panic = bool_to_yes_no(config('panicOnChecksumMismatch')) + + last_gen = get_gens()[-1] + last_gen_json = json.load(open(os.path.join(get_system_path('system', last_gen), 'boot.json'), 'r')) + last_gen_boot_spec = bootjson_to_bootspec(last_gen_json) + + config_file = str(config('extraConfig')) + '\n' + config_file += textwrap.dedent(f''' + timeout: {timeout} + editor_enabled: {editor_enabled} + hash_mismatch_panic: {hash_mismatch_panic} + graphics: yes + default_entry: {3 if len(last_gen_boot_spec.specialisations.items()) > 0 else 2} + ''') + + for wallpaper in config('style', 'wallpapers'): + config_file += f'''wallpaper: {get_copied_path_uri(wallpaper, 'wallpapers')}\n''' + + config_file += option_from_config('wallpaper_style', ['style', 'wallpaperStyle']) + config_file += option_from_config('backdrop', ['style', 'backdrop']) + + config_file += option_from_config('interface_resolution', ['style', 'interface', 'resolution']) + config_file += option_from_config('interface_branding', ['style', 'interface', 'branding']) + config_file += option_from_config('interface_branding_colour', ['style', 'interface', 'brandingColor']) + config_file += option_from_config('interface_help_hidden', ['style', 'interface', 'helpHidden']) + config_file += option_from_config('term_font_scale', ['style', 'graphicalTerminal', 'font', 'scale']) + config_file += option_from_config('term_font_spacing', ['style', 'graphicalTerminal', 'font', 'spacing']) + config_file += option_from_config('term_palette', ['style', 'graphicalTerminal', 'palette']) + config_file += option_from_config('term_palette_bright', ['style', 'graphicalTerminal', 'brightPalette']) + config_file += option_from_config('term_foreground', ['style', 'graphicalTerminal', 'foreground']) + config_file += option_from_config('term_background', ['style', 'graphicalTerminal', 'background']) + config_file += option_from_config('term_foreground_bright', ['style', 'graphicalTerminal', 'brightForeground']) + config_file += option_from_config('term_background_bright', ['style', 'graphicalTerminal', 'brightBackground']) + config_file += option_from_config('term_margin', ['style', 'graphicalTerminal', 'margin']) + config_file += option_from_config('term_margin_gradient', ['style', 'graphicalTerminal', 'marginGradient']) + + config_file += textwrap.dedent(''' + # NixOS boot entries start here + ''') + + for (profile, gens) in profiles: + # PATCHED: Changed from 'default profile' to '' for cleaner group name + group_name = '' if profile == 'system' else f"profile '{profile}'" + config_file += f'/+NixOS{group_name}\n' + + isFirst = True + + for gen in sorted(gens, key=lambda x: x, reverse=True): + config_file += generate_config_entry(profile, gen, isFirst) + isFirst = False + + config_file_path = os.path.join(limine_install_dir, 'limine.conf') + config_file += '\n# NixOS boot entries end here\n\n' + + config_file += str(config('extraEntries')) + + with open(f"{config_file_path}.tmp", 'w') as file: + file.truncate() + file.write(config_file.strip()) + file.flush() + os.fsync(file.fileno()) + os.rename(f"{config_file_path}.tmp", config_file_path) + + paths[config_file_path] = True + + for dest_path, source_path in config('additionalFiles').items(): + dest_path = os.path.join(limine_install_dir, dest_path) + + copy_file(source_path, dest_path) + + limine_binary = os.path.join(str(config('liminePath')), 'bin', 'limine') + cpu_family = config('hostArchitecture', 'family') + if config('efiSupport'): + boot_file = "" + if cpu_family == 'x86': + if config('hostArchitecture', 'bits') == 32: + boot_file = 'BOOTIA32.EFI' + elif config('hostArchitecture', 'bits') == 64: + boot_file = 'BOOTX64.EFI' + elif cpu_family == 'arm': + if config('hostArchitecture', 'arch') == 'armv8-a' and config('hostArchitecture', 'bits') == 64: + boot_file = 'BOOTAA64.EFI' + else: + raise Exception(f'Unsupported CPU arch: {config("hostArchitecture", "arch")}') + else: + raise Exception(f'Unsupported CPU family: {cpu_family}') + + efi_path = os.path.join(str(config('liminePath')), 'share', 'limine', boot_file) + dest_path = os.path.join(str(config('efiMountPoint')), 'efi', 'boot' if config('efiRemovable') else 'limine', boot_file) + + copy_file(efi_path, dest_path) + + if config('enrollConfig'): + b2sum = hashlib.blake2b() + b2sum.update(config_file.strip().encode()) + try: + subprocess.run([limine_binary, 'enroll-config', dest_path, b2sum.hexdigest()]) + except: + print('error: failed to enroll limine config.', file=sys.stderr) + sys.exit(1) + + if config('secureBoot', 'enable'): + sbctl = os.path.join(str(config('secureBoot', 'sbctl')), 'bin', 'sbctl') + if config('secureBoot', 'createAndEnrollKeys'): + print("TEST MODE: creating and enrolling keys") + try: + subprocess.run([sbctl, 'create-keys']) + except: + print('error: failed to create keys', file=sys.stderr) + sys.exit(1) + try: + subprocess.run([sbctl, 'enroll-keys', '--yes-this-might-brick-my-machine']) + except: + print('error: failed to enroll keys', file=sys.stderr) + sys.exit(1) + + print('signing limine...') + try: + subprocess.run([sbctl, 'sign', dest_path]) + except: + print('error: failed to sign limine', file=sys.stderr) + sys.exit(1) + + if not config('efiRemovable') and not config('canTouchEfiVariables'): + print('warning: boot.loader.efi.canTouchEfiVariables is set to false while boot.loader.limine.efiInstallAsRemovable.\n This may render the system unbootable.') + + if config('canTouchEfiVariables'): + if config('efiRemovable'): + print('note: boot.loader.limine.efiInstallAsRemovable is true, no need to add EFI entry.') + else: + efibootmgr = os.path.join(str(config('efiBootMgrPath')), 'bin', 'efibootmgr') + efi_partition = find_mounted_device(str(config('efiMountPoint'))) + efi_disk = find_disk_device(efi_partition) + + efibootmgr_output = subprocess.check_output([efibootmgr], stderr=subprocess.STDOUT, universal_newlines=True) + + # Check the output of `efibootmgr` to find if limine is already installed and present in the boot record + limine_boot_entry = None + if matches := re.findall(r'Boot([0-9a-fA-F]{4})\*? Limine', efibootmgr_output): + limine_boot_entry = matches[0] + + # If there's already a Limine entry, replace it + if limine_boot_entry: + boot_order = re.findall(r'BootOrder: ((?:[0-9a-fA-F]{4},?)*)', efibootmgr_output)[0] + + efibootmgr_output = subprocess.check_output([ + efibootmgr, + '-b', limine_boot_entry, + '-B', + ], stderr=subprocess.STDOUT, universal_newlines=True) + + efibootmgr_output = subprocess.check_output([ + efibootmgr, + '-c', + '-b', limine_boot_entry, + '-d', efi_disk, + '-p', efi_partition.removeprefix(efi_disk).removeprefix('p'), + '-l', f'\\efi\\limine\\{boot_file}', + '-L', 'Limine', + '-o', boot_order, + ], stderr=subprocess.STDOUT, universal_newlines=True) + else: + efibootmgr_output = subprocess.check_output([ + efibootmgr, + '-c', + '-d', efi_disk, + '-p', efi_partition.removeprefix(efi_disk).removeprefix('p'), + '-l', f'\\efi\\limine\\{boot_file}', + '-L', 'Limine', + ], stderr=subprocess.STDOUT, universal_newlines=True) + + if config('biosSupport'): + if cpu_family != 'x86': + raise Exception(f'Unsupported CPU family for BIOS install: {cpu_family}') + + limine_sys = os.path.join(str(config('liminePath')), 'share', 'limine', 'limine-bios.sys') + limine_sys_dest = os.path.join(limine_install_dir, 'limine-bios.sys') + + copy_file(limine_sys, limine_sys_dest) + + device = str(config('biosDevice')) + + if device == 'nodev': + print("note: boot.loader.limine.biosSupport is set, but device is set to nodev, only the stage 2 bootloader will be installed.", file=sys.stderr) + return + + limine_deploy_args: List[str] = [limine_binary, 'bios-install', device] + + if config('partitionIndex'): + limine_deploy_args.append(str(config('partitionIndex'))) + + if config('force'): + limine_deploy_args.append('--force') + + try: + subprocess.run(limine_deploy_args) + except: + raise Exception( + 'Failed to deploy BIOS stage 1 Limine bootloader!\n' + + 'You might want to try enabling the `boot.loader.limine.force` option.') + + print("removing unused boot files...") + for path in paths: + if not paths[path] and os.path.exists(path): + os.remove(path) + +def main() -> None: + try: + install_bootloader() + finally: + # Since fat32 provides little recovery facilities after a crash, + # it can leave the system in an unbootable state, when a crash/outage + # happens shortly after an update. To decrease the likelihood of this + # event sync the efi filesystem after each update. + rc = libc.syncfs(os.open(f"{str(config('efiMountPoint'))}", os.O_RDONLY)) + if rc != 0: + print(f"could not sync {str(config('efiMountPoint'))}: {os.strerror(rc)}", file=sys.stderr) + +if __name__ == '__main__': + main()