├── .gitignore ├── rustfmt.toml ├── Cargo.toml ├── LICENSE ├── flake.nix ├── README.md ├── flake.lock ├── isoimage └── config.nix ├── src ├── macros.rs ├── installer │ ├── systempkgs.rs │ └── networking.rs ├── main.rs ├── nixgen.rs └── drives.rs ├── CONTRIBUTING.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *tui-debug.log* 3 | *result 4 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | tab_spaces = 2 3 | edition = "2021" 4 | 5 | newline_style = "Unix" 6 | 7 | wrap_comments = true 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nixos-wizard" 3 | version = "0.3.2" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | ansi-to-tui = "7.0.0" 8 | anyhow = "1.0.98" 9 | env_logger = "0.11.8" 10 | fuzzy-matcher = "0.3.7" 11 | log = "0.4.27" 12 | nix = { version = "0.30.1", features = ["fs", "user"] } 13 | ratatui = { version = "0.29.0", features = ["all-widgets"] } 14 | serde = { version = "1.0.219", features = ["derive"] } 15 | serde_json = "1.0.141" 16 | strip-ansi-escapes = "0.2.1" 17 | tempfile = "3.20.0" 18 | throbber-widgets-tui = "0.8.0" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kyler Clay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Nixos TUI Installer"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | fenix.url = "github:nix-community/fenix"; 7 | disko.url = "github:nix-community/disko/latest"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, fenix, disko }@inputs: 11 | let 12 | system = "x86_64-linux"; 13 | mkRustToolchain = fenix.packages.${system}.complete.withComponents; 14 | pkgs = import nixpkgs { inherit system; }; 15 | diskoPkg = disko.packages.${system}.disko; 16 | nixosWizard = pkgs.rustPlatform.buildRustPackage { 17 | pname = "nixos-wizard"; 18 | version = "0.3.2"; 19 | 20 | src = self; 21 | 22 | cargoLock.lockFile = ./Cargo.lock; 23 | 24 | buildInputs = [ pkgs.makeWrapper ]; 25 | 26 | postInstall = '' 27 | wrapProgram $out/bin/nixos-wizard \ 28 | --prefix PATH : ${pkgs.lib.makeBinPath [ 29 | diskoPkg 30 | pkgs.bat 31 | pkgs.nixfmt-rfc-style 32 | pkgs.nixfmt-classic 33 | pkgs.util-linux 34 | pkgs.gawk 35 | pkgs.gnugrep 36 | pkgs.gnused 37 | pkgs.ntfs3g 38 | ]} 39 | ''; 40 | }; 41 | in 42 | { 43 | nixosConfigurations = { 44 | installerIso = nixpkgs.lib.nixosSystem { 45 | specialArgs = { inherit inputs nixosWizard; }; 46 | modules = [ 47 | ./isoimage/config.nix 48 | ]; 49 | }; 50 | }; 51 | 52 | packages.${system} = { 53 | default = nixosWizard; 54 | }; 55 | 56 | devShells.${system}.default = let 57 | toolchain = mkRustToolchain [ 58 | "cargo" 59 | "clippy" 60 | "rustfmt" 61 | "rustc" 62 | ]; 63 | in 64 | pkgs.mkShell { 65 | packages = [ toolchain pkgs.rust-analyzer ]; 66 | 67 | shellHook = '' 68 | export SHELL=${pkgs.zsh}/bin/zsh 69 | exec ${pkgs.zsh}/bin/zsh 70 | ''; 71 | }; 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

nixos-wizard

2 |
3 | A modern terminal-based NixOS installer, inspired by Arch Linux’s 4 | archinstall. 5 |
6 | 7 | ![nixos-wizard screenshot](https://github.com/user-attachments/assets/b1e11874-a72d-4e54-b2d8-e5a5f3325ac9) 8 | 9 | --- 10 | 11 | ## Why nixos-wizard? 12 | 13 | NixOS is an amazing distribution, but manual installations from a terminal have always been a tedious and error prone process from day one. Many tools have surfaced that can reliably automate this process, but the options for manual installations are scarce. 14 | 15 | This project aims to help you get a bootable NixOS system as quickly and easily as possible by providing: 16 | 17 | * A **text-based UI** for an intuitive installation experience 18 | * Interactive **disk partitioning and formatting** powered by [Disko](https://github.com/nix-community/disko) 19 | * Guided **user account creation**, **Home Manager configuration**, and **package selection** 20 | * Automatic **NixOS config generation** 21 | * Real-time progress feedback during installation 22 | 23 | --- 24 | 25 | ## Features 26 | 27 | * **Terminal UI** built with [Ratatui](https://github.com/ratatui/ratatui) 28 | * Partition disks and create filesystems easily 29 | * Configure users, groups, passwords, and even setup Home Manager. 30 | * Select system packages to install 31 | * Automatically generate and apply hardware-specific NixOS configurations 32 | * Supports installation inside a NixOS live environment (recommended) 33 | 34 | --- 35 | 36 | ## Requirements & Recommendations 37 | 38 | * Must be run **as root**. 39 | * Designed to run inside the **NixOS live environment** built from the project’s flake or ISO. A prebuilt installer ISO is included with each release. 40 | * Depends on NixOS-specific tools like `nixos-install` and `nixos-generate-config` being available. 41 | * A terminal emulator with proper color and Unicode support is recommended for best experience. 42 | * Running the binary directly may cause failures if necessary commands are not found in your environment. Ideally, this should be run using the flake output which wraps the program with all of the commands it needs for the installation process. 43 | 44 | --- 45 | 46 | ## Getting Started 47 | 48 | ### Development & Building 49 | 50 | Use Nix flakes to enter the dev shell or build the project: 51 | 52 | ```bash 53 | # Enter development shell with all dependencies 54 | nix develop 55 | 56 | # Build the release binary 57 | nix build 58 | ``` 59 | 60 | ### Running nixos-wizard 61 | 62 | If running inside the included installer ISO: 63 | 64 | ```bash 65 | sudo nixos-wizard 66 | ``` 67 | 68 | Alternatively, run the latest release from GitHub via Nix: 69 | 70 | ```bash 71 | sudo nix run github:km-clay/nixos-wizard 72 | ``` 73 | 74 | --- 75 | 76 | ## Building & Using the Installer ISO 77 | 78 | You can build a custom NixOS ISO image that includes `nixos-wizard` and all its dependencies pre-installed: 79 | 80 | ```bash 81 | nix build github:km-clay/nixos-wizard#nixosConfigurations.installerIso.config.system.build.isoImage 82 | ``` 83 | 84 | Boot this ISO on your target machine to run the installer in a fully-supported live environment. 85 | 86 | --- 87 | 88 | ## Roadmap 89 | 90 | * Add support for **btrfs subvolumes** and snapshots in disk configuration 91 | * Enable importing existing **flake inputs** or `configuration.nix` files for advanced customization 92 | * Improve hardware detection and configuration automation 93 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "disko": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs" 6 | }, 7 | "locked": { 8 | "lastModified": 1746728054, 9 | "narHash": "sha256-eDoSOhxGEm2PykZFa/x9QG5eTH0MJdiJ9aR00VAofXE=", 10 | "owner": "nix-community", 11 | "repo": "disko", 12 | "rev": "ff442f5d1425feb86344c028298548024f21256d", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "nix-community", 17 | "ref": "latest", 18 | "repo": "disko", 19 | "type": "github" 20 | } 21 | }, 22 | "fenix": { 23 | "inputs": { 24 | "nixpkgs": "nixpkgs_2", 25 | "rust-analyzer-src": "rust-analyzer-src" 26 | }, 27 | "locked": { 28 | "lastModified": 1753771482, 29 | "narHash": "sha256-7WnYHGi5xT4PCacjM/UVp+k4ZYIIXwCf6CjVqgUnGTQ=", 30 | "owner": "nix-community", 31 | "repo": "fenix", 32 | "rev": "8b6da138fb7baefa04a4284c63b2abefdfbd2c6d", 33 | "type": "github" 34 | }, 35 | "original": { 36 | "owner": "nix-community", 37 | "repo": "fenix", 38 | "type": "github" 39 | } 40 | }, 41 | "nixpkgs": { 42 | "locked": { 43 | "lastModified": 1746576598, 44 | "narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=", 45 | "owner": "NixOS", 46 | "repo": "nixpkgs", 47 | "rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "NixOS", 52 | "ref": "nixpkgs-unstable", 53 | "repo": "nixpkgs", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs_2": { 58 | "locked": { 59 | "lastModified": 1753694789, 60 | "narHash": "sha256-cKgvtz6fKuK1Xr5LQW/zOUiAC0oSQoA9nOISB0pJZqM=", 61 | "owner": "nixos", 62 | "repo": "nixpkgs", 63 | "rev": "dc9637876d0dcc8c9e5e22986b857632effeb727", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "nixos", 68 | "ref": "nixos-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "nixpkgs_3": { 74 | "locked": { 75 | "lastModified": 1753694789, 76 | "narHash": "sha256-cKgvtz6fKuK1Xr5LQW/zOUiAC0oSQoA9nOISB0pJZqM=", 77 | "owner": "nixos", 78 | "repo": "nixpkgs", 79 | "rev": "dc9637876d0dcc8c9e5e22986b857632effeb727", 80 | "type": "github" 81 | }, 82 | "original": { 83 | "owner": "nixos", 84 | "ref": "nixos-unstable", 85 | "repo": "nixpkgs", 86 | "type": "github" 87 | } 88 | }, 89 | "root": { 90 | "inputs": { 91 | "disko": "disko", 92 | "fenix": "fenix", 93 | "nixpkgs": "nixpkgs_3" 94 | } 95 | }, 96 | "rust-analyzer-src": { 97 | "flake": false, 98 | "locked": { 99 | "lastModified": 1753724566, 100 | "narHash": "sha256-DolKhpXhoehwLX+K/4xRRIeppnJHgKk6xWJdqn/vM6w=", 101 | "owner": "rust-lang", 102 | "repo": "rust-analyzer", 103 | "rev": "511c999bea1c3c129b8eba713bb9b809a9003d00", 104 | "type": "github" 105 | }, 106 | "original": { 107 | "owner": "rust-lang", 108 | "ref": "nightly", 109 | "repo": "rust-analyzer", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /isoimage/config.nix: -------------------------------------------------------------------------------- 1 | { pkgs, modulesPath, nixosWizard, ... }: { 2 | imports = [ 3 | "${modulesPath}/installer/cd-dvd/installation-cd-minimal.nix" 4 | ]; 5 | 6 | environment.etc."issue".text = '' 7 | \e[92m<<< NixOS 25.11.20250728.dc96378 \r (\m) - \l >>>\e[0m 8 | 9 | \e[38;5;27m▓▓▓▓ \e[38;5;81m▒▒▒▒ ▒▒▒▒ 10 | \e[38;5;27m▓▓▓▓ \e[38;5;81m▒▒▒▒ ▒▒▒▒ \e[38;5;27m ▓▓▓ 11 | \e[38;5;27m▓▓▓▓ \e[38;5;81m▒▒▒▒▒▒▒▒ \e[38;5;27m ▓▓▓ ▓▓ ▓▓▓▓▓ \e[38;5;81m ▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒ 12 | \e[38;5;27m▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓\e[38;5;81m▒▒▒▒▒▒ \e[38;5;27m▓▓ \e[38;5;27m ▓▓▓▓▓ ▓▓▓▓ ▓▓▓ \e[38;5;81m ▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒ 13 | \e[38;5;27m▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓\e[38;5;81m▒▒▒▒ \e[38;5;27m▓▓▓ \e[38;5;27m ▓▓▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒ ▒▒▒▒▒ 14 | \e[38;5;81m▒▒▒▒ \e[38;5;81m▒▒▒▒ \e[38;5;27m▓▓▓▓ \e[38;5;27m ▓▓▓▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒▒ ▒▒▒▒ ▒▒▒▒ 15 | \e[38;5;81m▒▒▒▒ \e[38;5;81m▒▒▒▒\e[38;5;27m▓▓▓▓ \e[38;5;27m ▓▓▓▓▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓ ▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒ 16 | \e[38;5;81m▒▒▒▒▒▒▒▒▒▒▒ \e[38;5;81m▒▒\e[38;5;27m▓▓▓▓ \e[38;5;27m ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒▒ 17 | \e[38;5;81m ▒▒▒▒▒▒▒▒▒\e[38;5;27m \e[38;5;27m▓▓▓▓▓▓▓▓▓ \e[38;5;27m ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓ ▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒▒▒▒▒▒ 18 | \e[38;5;81m▒▒▒▒\e[38;5;27m▓▓ \e[38;5;27m▓▓▓▓▓▓▓▓▓▓▓ \e[38;5;27m ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒▒▒▒▒▒ 19 | \e[38;5;81m▒▒▒▒\e[38;5;27m▓▓▓▓ \e[38;5;27m▓▓▓▓ \e[38;5;27m ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒▒ 20 | \e[38;5;81m▒▒▒▒ \e[38;5;27m▓▓▓▓ \e[38;5;27m▓▓▓▓ \e[38;5;27m ▓▓▓▓ ▓▓▓▓▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓ \e[38;5;81m ▒▒▒ ▒▒▒ ▒▒▒▒ 21 | \e[38;5;81m▒▒▒ \e[38;5;27m▓▓▓▓\e[38;5;81m▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ \e[38;5;27m ▓▓▓▓ ▓▓▓▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓▓▓ \e[38;5;81m ▒▒▒▒ ▒▒▒▒ ▒▒▒▒ 22 | \e[38;5;81m▒▒ \e[38;5;27m▓▓▓▓▓▓\e[38;5;81m▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ \e[38;5;27m ▓▓▓▓ ▓▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ 23 | \e[38;5;27m▓▓▓▓▓▓▓▓ \e[38;5;81m▒▒▒▒ \e[38;5;27m ▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓ ▓▓▓▓ \e[38;5;81m ▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒ 24 | \e[38;5;27m▓▓▓▓ ▓▓▓▓ \e[38;5;81m▒▒▒▒ \e[38;5;27m ▓▓ ▓▓▓ ▓▓▓ ▓▓▓ ▓▓▓ \e[38;5;81m ▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒ 25 | \e[38;5;27m▓▓▓▓ ▓▓▓▓ \e[38;5;81m▒▒▒▒\e[0m 26 | 27 | The "nixos" and "root" accounts both have \e[33mempty passwords\e[0m. Login using `\e[1;35mssh\e[0m` requires a password to be set using the `\e[1;35mpasswd\e[0m` command. 28 | 29 | To set up a wireless connection, run `\e[1;35mnmtui\e[0m`. 30 | 31 | Run `\e[1;35msudo nixos-wizard\e[0m` to enter the installer. 32 | Run `\e[1;35mnixos-help\e[0m` for the NixOS manual. 33 | 34 | ''; 35 | 36 | environment.systemPackages = [ 37 | pkgs.nixfmt 38 | pkgs.nixfmt-classic 39 | nixosWizard 40 | ]; 41 | 42 | nix.settings.experimental-features = [ "nix-command" "flakes" ]; 43 | 44 | nixpkgs.hostPlatform = "x86_64-linux"; 45 | networking.networkmanager.enable = true; 46 | } 47 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Create a std::process::Command with optional arguments 2 | /// 3 | /// This macro simplifies command creation by: 4 | /// - Automatically importing std::process::Command 5 | /// - Converting all arguments to strings 6 | /// - Supporting both command-only and command-with-args patterns 7 | /// 8 | /// Examples: 9 | /// ``` 10 | /// let cmd1 = command!("ls"); 11 | /// let cmd2 = command!("git", "status", "--porcelain"); 12 | /// ``` 13 | #[macro_export] 14 | macro_rules! command { 15 | // Command with arguments 16 | ($cmd:expr, $($arg:expr),* $(,)?) => {{ 17 | use std::process::Command; 18 | let mut c = Command::new($cmd); 19 | c.args(&[$($arg.to_string()),*]); 20 | c 21 | }}; 22 | // Command without arguments 23 | ($cmd:expr) => {{ 24 | use std::process::Command; 25 | let c = Command::new($cmd); 26 | c 27 | }}; 28 | } 29 | 30 | #[macro_export] 31 | /// Generate Nix attribute set syntax from Rust expressions 32 | /// 33 | /// This macro creates properly formatted Nix attribute sets: 34 | /// - Keys are automatically quoted if needed 35 | /// - Values are inserted as-is (use nixstr() for string literals) 36 | /// - Produces valid Nix syntax ready for inclusion in configs 37 | /// 38 | /// Example: 39 | /// ``` 40 | /// let attrs = attrset! { 41 | /// "services.nginx.enable" = "true"; 42 | /// "networking.hostName" = nixstr("myhost"); 43 | /// }; 44 | /// // Produces: { services.nginx.enable = true; networking.hostName = "myhost"; } 45 | /// ``` 46 | macro_rules! attrset { 47 | {$($key:tt = $val:expr);+ ;} => {{ 48 | let mut parts = vec![]; 49 | $( 50 | // Remove quotes from string literals for clean Nix attribute names 51 | parts.push(format!("{} = {};", stringify!($key).trim_matches('"'), $val)); 52 | )* 53 | format!("{{ {} }}", parts.join(" ")) 54 | }}; 55 | } 56 | 57 | #[macro_export] 58 | /// Merge multiple Nix attribute sets into one 59 | /// 60 | /// This macro combines multiple attribute sets by: 61 | /// - Extracting the contents from each set (removing outer braces) 62 | /// - Concatenating all attributes 63 | /// - Wrapping the result in new braces 64 | /// - Validating that inputs are properly formatted attribute sets 65 | /// 66 | /// Example: 67 | /// ``` 68 | /// let set1 = attrset! { "a" = "1"; }; 69 | /// let set2 = attrset! { "b" = "2"; }; 70 | /// let combined = merge_attrs!(set1, set2); 71 | /// // Produces: { a = 1; b = 2; } 72 | /// ``` 73 | macro_rules! merge_attrs { 74 | ($($set:expr),* $(,)?) => {{ 75 | let mut merged = String::new(); 76 | $( 77 | if !$set.is_empty() { 78 | // Validate that we have a proper attribute set 79 | if !$set.starts_with('{') || !$set.ends_with('}') { 80 | panic!("attrset must be a valid attribute set, got: {:?}", $set); 81 | } 82 | // Extract the inner content (without braces) 83 | let inner = $set 84 | .strip_prefix('{') 85 | .and_then(|s| s.strip_suffix('}')) 86 | .unwrap_or("") 87 | .trim(); 88 | merged.push_str(inner); 89 | } 90 | )* 91 | // Wrap the merged content in braces 92 | format!("{{ {merged} }}") 93 | }}; 94 | } 95 | 96 | #[macro_export] 97 | /// Generate Nix list syntax from Rust expressions 98 | /// 99 | /// Creates properly formatted Nix lists with space-separated elements: 100 | /// - Each item is converted to string representation 101 | /// - Items are joined with spaces (Nix list syntax) 102 | /// - Produces valid Nix syntax ready for use in configurations 103 | /// 104 | /// Example: 105 | /// ``` 106 | /// let packages = list!["git", "vim", "firefox"]; 107 | /// // Produces: [git vim firefox] 108 | /// ``` 109 | macro_rules! list { 110 | ($($item:expr),* $(,)?) => { 111 | { 112 | let items = vec![$(format!("{}", $item)),*]; 113 | format!("[{}]", items.join(" ")) 114 | } 115 | }; 116 | } 117 | 118 | // UI Navigation Macros 119 | // These macros provide consistent keyboard shortcuts across the TUI 120 | 121 | #[macro_export] 122 | /// Keys for closing/quitting: Escape or 'q' 123 | macro_rules! ui_close { 124 | () => { 125 | KeyCode::Esc | KeyCode::Char('q') 126 | }; 127 | } 128 | 129 | #[macro_export] 130 | /// Keys for going back: Escape, 'q', Left arrow, or 'h' (vi-style) 131 | macro_rules! ui_back { 132 | () => { 133 | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Left | KeyCode::Char('h') 134 | }; 135 | } 136 | 137 | #[macro_export] 138 | /// Keys for entering/selecting: Enter, Right arrow, or 'l' (vi-style) 139 | macro_rules! ui_enter { 140 | () => { 141 | KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') 142 | }; 143 | } 144 | 145 | #[macro_export] 146 | /// Keys for moving down: Down arrow or 'j' (vi-style) 147 | macro_rules! ui_down { 148 | () => { 149 | KeyCode::Down | KeyCode::Char('j') 150 | }; 151 | } 152 | 153 | #[macro_export] 154 | /// Keys for moving up: Up arrow or 'k' (vi-style) 155 | macro_rules! ui_up { 156 | () => { 157 | KeyCode::Up | KeyCode::Char('k') 158 | }; 159 | } 160 | 161 | #[macro_export] 162 | /// Keys for moving left: Left arrow or 'h' (vi-style) 163 | macro_rules! ui_left { 164 | () => { 165 | KeyCode::Left | KeyCode::Char('h') 166 | }; 167 | } 168 | #[macro_export] 169 | /// Keys for moving right: Right arrow or 'l' (vi-style) 170 | macro_rules! ui_right { 171 | () => { 172 | KeyCode::Right | KeyCode::Char('l') 173 | }; 174 | } 175 | 176 | #[macro_export] 177 | /// Split a screen area vertically with specified constraints 178 | /// 179 | /// Creates a vertical layout that divides the given area into rows. 180 | /// Each constraint defines how much space each row should take. 181 | /// 182 | /// Example: 183 | /// ``` 184 | /// let chunks = split_vert!(area, 1, [Constraint::Length(3), Constraint::Min(0)]); 185 | /// // Creates two rows: first is 3 units tall, second takes remaining space 186 | /// ``` 187 | macro_rules! split_vert { 188 | ($area:expr, $margin:expr, $constraints:expr) => {{ 189 | use ratatui::layout::{Constraint, Direction, Layout}; 190 | Layout::default() 191 | .direction(Direction::Vertical) 192 | .margin($margin) 193 | .constraints($constraints) 194 | .split($area) 195 | }}; 196 | } 197 | 198 | #[macro_export] 199 | /// Split a screen area horizontally with specified constraints 200 | /// 201 | /// Creates a horizontal layout that divides the given area into columns. 202 | /// Each constraint defines how much space each column should take. 203 | /// 204 | /// Example: 205 | /// ``` 206 | /// let chunks = split_hor!(area, 0, [Constraint::Percentage(50), Constraint::Percentage(50)]); 207 | /// // Creates two equal-width columns 208 | /// ``` 209 | macro_rules! split_hor { 210 | ($area:expr, $margin:expr, $constraints:expr) => {{ 211 | use ratatui::layout::{Constraint, Direction, Layout}; 212 | Layout::default() 213 | .direction(Direction::Horizontal) 214 | .margin($margin) 215 | .constraints($constraints) 216 | .split($area) 217 | }}; 218 | } 219 | -------------------------------------------------------------------------------- /src/installer/systempkgs.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, sync::LazyLock}; 2 | 3 | use ratatui::{layout::Constraint, text::Line}; 4 | use serde_json::Value; 5 | 6 | use crate::{ 7 | installer::{Installer, Page, Signal}, 8 | styled_block, 9 | widget::{ConfigWidget, PackagePicker, TableWidget}, 10 | }; 11 | 12 | use std::{ 13 | sync::{Arc, RwLock}, 14 | thread, 15 | }; 16 | 17 | pub static NIXPKGS: LazyLock>>>> = 18 | LazyLock::new(|| Arc::new(RwLock::new(None))); 19 | 20 | pub fn init_nixpkgs() { 21 | let pkgs_ref = NIXPKGS.clone(); 22 | thread::spawn(move || { 23 | let pkgs = fetch_nixpkgs().unwrap_or_else(|e| { 24 | eprintln!("Failed to fetch nixpkgs: {e}"); 25 | vec![] 26 | }); 27 | let mut pkgs_lock = pkgs_ref.write().unwrap(); 28 | *pkgs_lock = Some(pkgs); 29 | }); 30 | } 31 | 32 | pub fn fetch_nixpkgs() -> anyhow::Result> { 33 | let json: Value = { 34 | /* 35 | TODO: find a better way to do this? it kind of sucks 36 | let output = Command::new("nix") 37 | .args(["--extra-experimental-features", "nix-command flakes", "search", "nixpkgs", "^", "--json"]) 38 | .output()?; 39 | */ 40 | let precomputed = include_str!("../../pkgs.json"); 41 | serde_json::from_str(precomputed)? 42 | }; 43 | let pkgs_object = json 44 | .as_object() 45 | .ok_or_else(|| anyhow::anyhow!("Expected JSON object"))?; 46 | 47 | let mut pkgs = Vec::with_capacity(pkgs_object.len()); 48 | 49 | for key in pkgs_object.keys() { 50 | let stripped = key 51 | .strip_prefix("legacyPackages.x86_64-linux.") 52 | .unwrap_or(key); 53 | pkgs.push(stripped.to_string()); 54 | } 55 | 56 | let mut seen = HashSet::new(); 57 | pkgs.retain(|pkg| seen.insert(pkg.clone())); 58 | 59 | Ok(pkgs) 60 | } 61 | 62 | pub fn get_available_pkgs() -> anyhow::Result> { 63 | let mut retries = 0; 64 | loop { 65 | let guard = NIXPKGS.read().unwrap(); 66 | if let Some(nixpkgs) = guard.as_ref() { 67 | // Great, the package list has been populated 68 | break Ok(nixpkgs.clone()); 69 | } 70 | drop(guard); // Release lock before sleeping 71 | 72 | if retries >= 5 { 73 | // Last attempt to grab the package list before breaking 74 | break Ok(fetch_nixpkgs().unwrap_or_default()); 75 | } 76 | 77 | std::thread::sleep(std::time::Duration::from_millis(500)); 78 | retries += 1; 79 | } 80 | } 81 | 82 | pub struct SystemPackages { 83 | package_picker: PackagePicker, 84 | } 85 | 86 | impl SystemPackages { 87 | pub fn new(selected_pkgs: Vec, available_pkgs: Vec) -> Self { 88 | let package_picker = PackagePicker::new( 89 | "Selected Packages", 90 | "Available Packages", 91 | selected_pkgs, 92 | available_pkgs, 93 | ); 94 | 95 | Self { package_picker } 96 | } 97 | pub fn display_widget(installer: &mut Installer) -> Option> { 98 | let sys_pkgs: Vec> = installer 99 | .system_pkgs 100 | .clone() 101 | .into_iter() 102 | .map(|item| vec![item]) 103 | .collect(); 104 | if sys_pkgs.is_empty() { 105 | return None; 106 | } 107 | Some(Box::new(TableWidget::new( 108 | "", 109 | vec![Constraint::Percentage(100)], 110 | vec!["Packages".into()], 111 | sys_pkgs, 112 | )) as Box) 113 | } 114 | pub fn page_info<'a>() -> (String, Vec>) { 115 | ( 116 | "System Packages".to_string(), 117 | styled_block(vec![vec![( 118 | None, 119 | "Select extra system packages to include in the configuration", 120 | )]]), 121 | ) 122 | } 123 | } 124 | 125 | impl Page for SystemPackages { 126 | fn render( 127 | &mut self, 128 | _installer: &mut super::Installer, 129 | f: &mut ratatui::Frame, 130 | area: ratatui::prelude::Rect, 131 | ) { 132 | self.package_picker.render(f, area); 133 | } 134 | 135 | fn handle_input( 136 | &mut self, 137 | installer: &mut super::Installer, 138 | event: ratatui::crossterm::event::KeyEvent, 139 | ) -> super::Signal { 140 | use ratatui::crossterm::event::KeyCode; 141 | 142 | // Handle quit/escape at the top level 143 | match event.code { 144 | KeyCode::Esc | KeyCode::Char('q') => return Signal::Pop, 145 | _ => {} 146 | } 147 | 148 | // Store the current selected packages before handling input 149 | let previous_selection = self.package_picker.get_selected_packages(); 150 | 151 | // Handle the input with the package picker 152 | let signal = self.package_picker.handle_input(event); 153 | 154 | // Update installer's system_pkgs if the selection changed 155 | let current_selection = self.package_picker.get_selected_packages(); 156 | if previous_selection != current_selection { 157 | installer.system_pkgs = current_selection; 158 | } 159 | 160 | signal 161 | } 162 | 163 | fn get_help_content(&self) -> (String, Vec>) { 164 | let help_content = styled_block(vec![ 165 | vec![ 166 | ( 167 | Some(( 168 | ratatui::style::Color::Yellow, 169 | ratatui::style::Modifier::BOLD, 170 | )), 171 | "Tab", 172 | ), 173 | (None, " - Switch between lists and search"), 174 | ], 175 | vec![ 176 | ( 177 | Some(( 178 | ratatui::style::Color::Yellow, 179 | ratatui::style::Modifier::BOLD, 180 | )), 181 | "↑/↓, j/k", 182 | ), 183 | (None, " - Navigate package lists"), 184 | ], 185 | vec![ 186 | ( 187 | Some(( 188 | ratatui::style::Color::Yellow, 189 | ratatui::style::Modifier::BOLD, 190 | )), 191 | "Enter", 192 | ), 193 | (None, " - Add/remove package to/from selection"), 194 | ], 195 | vec![ 196 | ( 197 | Some(( 198 | ratatui::style::Color::Yellow, 199 | ratatui::style::Modifier::BOLD, 200 | )), 201 | "/", 202 | ), 203 | (None, " - Focus search bar"), 204 | ], 205 | vec![ 206 | ( 207 | Some(( 208 | ratatui::style::Color::Yellow, 209 | ratatui::style::Modifier::BOLD, 210 | )), 211 | "Esc", 212 | ), 213 | (None, " - Return to main menu"), 214 | ], 215 | vec![ 216 | ( 217 | Some(( 218 | ratatui::style::Color::Yellow, 219 | ratatui::style::Modifier::BOLD, 220 | )), 221 | "?", 222 | ), 223 | (None, " - Show this help"), 224 | ], 225 | vec![(None, "")], 226 | vec![(None, "Search filters packages in real-time as you type.")], 227 | vec![(None, "Filter persists when adding/removing packages.")], 228 | vec![( 229 | None, 230 | "Selected packages will be installed on your NixOS system.", 231 | )], 232 | ]); 233 | ("System Packages".to_string(), help_content) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | This doc is designed to give a high level overview of how this codebase works, to make contributing to it easier. 4 | 5 | ## Setting up the dev environment 6 | 7 | The flake included in the project root contains a dev shell which will give you all of the tools you need to work on the project. If you're on NixOS or have `nixpkgs` installed on your machine, you can just use 8 | ```bash 9 | nix develop 10 | ``` 11 | 12 | If not, make sure you have cargo installed. Also, run cargo fmt before you make any commits please :) 13 | 14 | ## nixos-wizard Architecture Overview 15 | 16 | The program itself has five core components: 17 | 18 | 1. The event loop - manages current UI and installer state 19 | 2. The `Page` trait - defines the main UI screens, essentially containers for widgets 20 | 3. The `ConfigWidget` trait - re-usable UI components that make up pages 21 | 4. The `Installer` struct - contains all of the information input by the user 22 | 5. The `nixgen` module - responsible for serializing the `Installer` struct into a `configuration.nix` file 23 | 24 | ### The event loop 25 | The event loop contains a stack of `Box`, and whenever a page is entered, that page is pushed onto the stack. Whenever a page is exited, that page is popped from the stack. Every iteration of the event loop does two things: 26 | * Calls the `render()` method of the page on top of the stack 27 | * Polls for user input, and if any is received, passes that input to the `handle_input()` method of the page on top of the stack. 28 | The pages communicate with the event loop using the `Signal` enum. `Signal::Pop` makes the event loop pop from the page stack, for instance. 29 | 30 | ### The `Page` trait 31 | The `Page` trait is the main interface used to define the different pages of the installer. The main methods of this trait are `render()` and `handle_input()`. Each page is itself a collection of widgets, which each implement the `ConfigWidget` trait. Pages are navigated to by returning `Signal::Push(Box::new())` from the `handle_input()` method, which tells the event loop to push a new page onto the stack. Pages are navigated away from using `Signal::Pop`. 32 | 33 | ### The `ConfigWidget` trait 34 | The `ConfigWidget` trait is the main interface used to define page components. Like `Page`, the `ConfigWidget` trait exposes `render()` and `handle_input()`. `handle_input()` is useful when input *must* be passed to the widget using the interface, like in the case of said widget being stored as a trait object. `render()` is usually given a chunk of the screen by it's `Page` to try to render inside of. 35 | 36 | Generally speaking, inputs are caught and handled at the page level, as delegating all input to the individual widgets ends up fostering more presumptuous or general logic, where page-specific logic is generally more favorable in this case. 37 | 38 | The trickiest part of setting up new `Page` or `ConfigWidget` structs is defining how they use the space that they are given in their respective `render()` methods. Take this for example: 39 | 40 | ```rust 41 | impl Page for EnableFlakes { 42 | fn render(&mut self, _installer: &mut Installer, f: &mut Frame, area: Rect) { 43 | let chunks = Layout::default() 44 | .direction(Direction::Vertical) 45 | .margin(1) 46 | .constraints( 47 | [ 48 | Constraint::Percentage(40), 49 | Constraint::Percentage(60) 50 | ].as_ref() 51 | ) 52 | .split(area); 53 | 54 | let hor_chunks = Layout::default() 55 | .direction(Direction::Horizontal) 56 | .margin(1) 57 | .constraints( 58 | [ 59 | Constraint::Percentage(30), 60 | Constraint::Percentage(40), 61 | Constraint::Percentage(30), 62 | ] 63 | .as_ref(), 64 | ) 65 | .split(chunks[1]); 66 | 67 | let info_box = InfoBox::new( 68 | "", 69 | ... info box content ... 70 | ); 71 | info_box.render(f, chunks[0]); 72 | self.buttons.render(f, hor_chunks[1]); 73 | self.help_modal.render(f, area); 74 | } 75 | ... 76 | ``` 77 | 78 | This is the `render()` method of the "Enable Flakes" page. It cuts up the space given to it vertically first, and then horizontally. 79 | 80 | The method uses Ratatui's `Layout` system to divide the terminal screen area into smaller rectangular chunks. First, it splits the available space vertically into two regions: the top 40% (for the `info_box`) and the bottom 60%. Then it subdivides the bottom 60% horizontally into three parts: 30%, 40%, and 30%. The middle horizontal chunk is used to render the `buttons` widget. 81 | 82 | Each widget’s `render()` method is called with the frame and the specific chunk of the terminal space it should draw itself within. This way, each widget knows exactly how much space it has, and where it should be positioned on the screen. 83 | 84 | This approach of dividing and subdividing the UI space using Ratatui’s layout tools allows pages to arrange their child widgets precisely and responsively, adapting to terminal size changes. 85 | 86 | ## The `Installer` Struct 87 | 88 | The `Installer` struct (defined in `src/installer/mod.rs`) serves as the central data store for all user configuration choices throughout the installation process. It acts as the single source of truth that gets populated as users navigate through different pages and make selections. 89 | 90 | ### Key Fields 91 | 92 | The struct contains fields for every configurable aspect of a NixOS installation: 93 | 94 | **System Configuration:** 95 | - `hostname`, `timezone`, `locale`, `language` - Basic system settings 96 | - `keyboard_layout` - Keyboard layout configuration 97 | - `enable_flakes` - Whether to enable Nix flakes support 98 | - `bootloader` - Boot loader choice (e.g., "systemd-boot", "grub") 99 | 100 | **Hardware & Storage:** 101 | - `drives` - Vector of `Disk` objects representing storage configuration 102 | - `use_swap` - Whether to enable swap partition 103 | - `kernels` - Available kernel options 104 | - `audio_backend` - Audio system configuration 105 | 106 | **User Management:** 107 | - `root_passwd_hash` - Hashed root password 108 | - `users` - Vector of `User` structs containing user account information 109 | 110 | **Desktop Environment:** 111 | - `desktop_environment` - Selected DE (e.g., "KDE Plasma", "GNOME") 112 | - `greeter` - Display manager choice 113 | - `profile` - Installation profile selection 114 | 115 | **Packages & Services:** 116 | - `system_pkgs` - Vector of system packages to install 117 | - `network_backend` - Network management system 118 | - `flake_path` - Optional path to user's flake configuration 119 | 120 | ### Key Methods 121 | 122 | The `Installer` struct provides several important methods: 123 | 124 | - `new()` - Creates a new instance with default values 125 | - `has_all_requirements()` - Validates that all required fields are populated before installation can proceed. Checks for root password, at least one user, drive configuration, and bootloader selection. 126 | 127 | ### Usage Pattern 128 | 129 | Throughout the application, pages and widgets receive a mutable reference to the `Installer` struct, allowing them to read current values and update fields based on user input. This centralized approach ensures data consistency and makes it easy to validate the complete configuration before proceeding with installation. 130 | 131 | ## The `nixgen` Module 132 | 133 | The `nixgen` module (located in `src/nixgen.rs`) is responsible for converting the user's configuration choices stored in the `Installer` struct into valid Nix configuration files. This module serves as the bridge between the TUI application and the actual NixOS configuration system. 134 | 135 | ### Core Components 136 | 137 | **NixWriter Struct:** 138 | The main component is the `NixWriter` struct, which takes a JSON `Value` representation of the configuration and provides methods to generate different types of Nix configuration files. 139 | 140 | **Key Functions:** 141 | - `nixstr(val)` - Utility function that wraps strings in quotes for valid Nix syntax 142 | - `fmt_nix(nix)` - Formats generated Nix code using the `nixfmt` tool 143 | - `highlight_nix(nix)` - Syntax highlights Nix code using `bat` for display purposes 144 | - `attrset!` - A macro that allows you to write Nix attribute sets. Returns a `String`. 145 | 146 | ### Configuration Generation 147 | 148 | The `NixWriter` generates two main types of configuration: 149 | 150 | **System Configuration (`write_sys_config`):** 151 | - Converts user choices into a complete `configuration.nix` file 152 | - Handles system-level settings like networking, desktop environments, users, and services 153 | - Uses helper functions like `parse_network_backend()` and `parse_locale()` to convert user-friendly selections into proper Nix attribute sets 154 | - Manages conditional logic for features like Home Manager integration 155 | 156 | **Disko Configuration (`write_disko_config`):** 157 | - Generates disk partitioning and filesystem configuration 158 | - Creates the declarative disk setup that Disko will execute during installation 159 | - Handles different storage configurations based on user selections 160 | 161 | ### Output Structure 162 | 163 | The `write_configs()` method returns a `Configs` struct containing: 164 | - `system` - The complete NixOS system configuration as a Nix string 165 | - `disko` - The disk configuration for the Disko tool 166 | - `flake_path` - Optional path to user's existing flake configuration 167 | 168 | ### Architecture Benefits 169 | 170 | This separation allows the installer to: 171 | 1. Maintain a clean separation between UI logic and configuration generation 172 | 2. Generate human-readable, properly formatted Nix configurations 173 | 3. Support both traditional NixOS configurations and flake-based setups 174 | 4. Provide immediate validation and preview of the generated configurations before installation 175 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{env, io}; 2 | 3 | use log::debug; 4 | use ratatui::crossterm::event::{self, Event}; 5 | use ratatui::{ 6 | Terminal, 7 | crossterm::{ 8 | execute, 9 | terminal::{ 10 | Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, 11 | enable_raw_mode, 12 | }, 13 | }, 14 | layout::Alignment, 15 | prelude::CrosstermBackend, 16 | style::{Color, Modifier, Style}, 17 | text::Line, 18 | widgets::Paragraph, 19 | }; 20 | use std::time::{Duration, Instant}; 21 | use tempfile::NamedTempFile; 22 | 23 | use crate::installer::{InstallProgress, Installer, Menu, Page, Signal, systempkgs::init_nixpkgs}; 24 | 25 | pub mod drives; 26 | pub mod installer; 27 | #[macro_use] 28 | pub mod macros; 29 | pub mod nixgen; 30 | pub mod widget; 31 | 32 | type LineStyle = Option<(Color, Modifier)>; 33 | pub fn styled_block<'a>(lines: Vec>) -> Vec> { 34 | lines 35 | .into_iter() 36 | .map(|line| { 37 | let spans = line 38 | .into_iter() 39 | .map(|(style_opt, text)| { 40 | let mut span = ratatui::text::Span::raw(text.to_string()); 41 | if let Some((color, modifier)) = style_opt { 42 | span.style = Style::default().fg(color).add_modifier(modifier); 43 | } 44 | span 45 | }) 46 | .collect::>(); 47 | Line::from(spans) 48 | }) 49 | .collect() 50 | } 51 | 52 | /// RAII guard to ensure terminal state is properly cleaned up 53 | /// when the TUI exits, either normally or via panic 54 | struct RawModeGuard; 55 | 56 | impl RawModeGuard { 57 | fn new(stdout: &mut io::Stdout) -> anyhow::Result { 58 | // Enable raw mode to capture all keyboard input directly 59 | enable_raw_mode()?; 60 | 61 | // Special handling for "linux" terminal (e.g., TTY console) 62 | // In dumb terminals, entering alternate screen doesn't auto-clear, 63 | // so we need to explicitly clear to avoid rendering artifacts 64 | if let Ok("linux") = env::var("TERM").as_deref() { 65 | execute!(stdout, Clear(ClearType::All))?; 66 | } 67 | 68 | // Enter alternate screen buffer to preserve user's terminal content 69 | execute!(stdout, EnterAlternateScreen)?; 70 | Ok(Self) 71 | } 72 | } 73 | 74 | /// Cleanup terminal state when the guard is dropped 75 | /// This ensures proper restoration even if the program panics 76 | impl Drop for RawModeGuard { 77 | fn drop(&mut self) { 78 | // Ignore errors during cleanup - we're likely panicking or shutting down 79 | let _ = disable_raw_mode(); 80 | let _ = execute!(io::stdout(), LeaveAlternateScreen); 81 | } 82 | } 83 | 84 | fn main() -> anyhow::Result<()> { 85 | if env::args().any(|arg| arg == "--version") { 86 | let version = env!("CARGO_PKG_VERSION"); 87 | println!("nixos-wizard version {version}"); 88 | return Ok(()); 89 | } 90 | 91 | let uid = nix::unistd::getuid(); 92 | log::debug!("UID: {uid}"); 93 | if uid.as_raw() != 0 { 94 | return Err(anyhow::anyhow!( 95 | "nixos-wizard: This installer must be run as root." 96 | )); 97 | } 98 | // Set up panic handler to gracefully restore terminal state 99 | // This prevents leaving the user's terminal in an unusable state 100 | // if the installer crashes unexpectedly 101 | std::panic::set_hook(Box::new(|panic_info| { 102 | use ratatui::crossterm::{ 103 | execute, 104 | terminal::{LeaveAlternateScreen, disable_raw_mode}, 105 | }; 106 | 107 | // Attempt to restore terminal state - ignore errors since we're panicking 108 | let _ = disable_raw_mode(); 109 | let _ = execute!(io::stdout(), LeaveAlternateScreen); 110 | 111 | // Print user-friendly panic information to stderr 112 | eprintln!("=================================================="); 113 | eprintln!("NIXOS INSTALLER PANIC - Terminal state restored!"); 114 | eprintln!("=================================================="); 115 | eprintln!("Panic occurred: {panic_info}"); 116 | eprintln!("=================================================="); 117 | })); 118 | 119 | env_logger::init(); 120 | debug!("Logger initialized"); 121 | init_nixpkgs(); 122 | 123 | let mut stdout = io::stdout(); 124 | let res = { 125 | let _raw_guard = RawModeGuard::new(&mut stdout)?; 126 | let backend = CrosstermBackend::new(stdout); 127 | let mut terminal = Terminal::new(backend)?; 128 | debug!("Running TUI"); 129 | run_app(&mut terminal) 130 | }; 131 | 132 | debug!("Exiting TUI"); 133 | 134 | res 135 | } 136 | 137 | /// Processes signals from UI pages to control navigation and installer actions 138 | /// Returns Ok(true) if the application should quit, Ok(false) to continue 139 | fn handle_signal( 140 | signal: Signal, 141 | page_stack: &mut Vec>, 142 | installer: &mut Installer, 143 | ) -> anyhow::Result { 144 | match signal { 145 | Signal::Wait => { 146 | // Do nothing 147 | } 148 | Signal::Push(new_page) => { 149 | page_stack.push(new_page); 150 | } 151 | Signal::Pop => { 152 | page_stack.pop(); 153 | } 154 | Signal::PopCount(n) => { 155 | // Pop n pages from the stack, but never remove the root page 156 | for _ in 0..n { 157 | if page_stack.len() > 1 { 158 | page_stack.pop(); 159 | } 160 | } 161 | } 162 | Signal::Unwind => { 163 | // Return to the main menu by removing all pages except the root 164 | while page_stack.len() > 1 { 165 | page_stack.pop(); 166 | } 167 | } 168 | Signal::Quit => { 169 | debug!("Quit signal received"); 170 | return Ok(true); // Signal to quit 171 | } 172 | Signal::WriteCfg => { 173 | use std::io::Write; 174 | debug!("WriteCfg signal received - starting installation process"); 175 | 176 | // Convert installer state to JSON for the Nix configuration generator 177 | let config_json = installer.to_json()?; 178 | debug!( 179 | "Generated config JSON: {}", 180 | serde_json::to_string_pretty(&config_json)? 181 | ); 182 | 183 | // Generate NixOS system and disko (disk partitioning) configurations 184 | let serializer = crate::nixgen::NixWriter::new(config_json); 185 | 186 | match serializer.write_configs() { 187 | Ok(cfg) => { 188 | debug!("system config: {}", cfg.system); 189 | debug!("disko config: {}", cfg.disko); 190 | debug!("flake_path: {:?}", cfg.flake_path); 191 | 192 | // Create temporary files to hold the generated configurations 193 | let mut system_cfg = NamedTempFile::new()?; 194 | let mut disko_cfg = NamedTempFile::new()?; 195 | 196 | write!(system_cfg, "{}", cfg.system)?; 197 | write!(disko_cfg, "{}", cfg.disko)?; 198 | 199 | // Navigate to the installation progress page 200 | page_stack.push(Box::new(InstallProgress::new( 201 | installer.clone(), 202 | system_cfg, 203 | disko_cfg, 204 | )?)); 205 | } 206 | Err(e) => { 207 | debug!("Failed to write configuration files: {e}"); 208 | return Err(anyhow::anyhow!("Configuration write failed: {e}")); 209 | } 210 | } 211 | } 212 | Signal::Error(err) => { 213 | return Err(anyhow::anyhow!("{}", err)); 214 | } 215 | } 216 | Ok(false) // Continue running 217 | } 218 | 219 | /// Main TUI event loop that manages the installer interface 220 | /// 221 | /// This function implements a page-based navigation system using a stack: 222 | /// - Pages are pushed/popped based on user navigation 223 | /// - Each page can send signals to control the overall application flow 224 | /// - The event loop handles both user input and periodic updates (ticks) 225 | pub fn run_app(terminal: &mut Terminal>) -> anyhow::Result<()> { 226 | let mut installer = Installer::new(); 227 | let mut page_stack: Vec> = vec![]; 228 | page_stack.push(Box::new(Menu::new())); 229 | 230 | // Set up timing for periodic updates (10 FPS) 231 | let tick_rate = Duration::from_millis(100); 232 | let mut last_tick = Instant::now(); 233 | 234 | loop { 235 | // Render the current UI state 236 | terminal.draw(|f| { 237 | let chunks = split_vert!( 238 | f.area(), 239 | 0, 240 | [ 241 | Constraint::Length(1), // Header height 242 | Constraint::Min(0), // Rest of screen 243 | ] 244 | ); 245 | 246 | // Create three-column header: help text, title, and empty space 247 | let header_chunks = split_hor!( 248 | chunks[0], 249 | 0, 250 | [ 251 | Constraint::Percentage(33), // Left: help text 252 | Constraint::Percentage(34), // Center: application title 253 | Constraint::Percentage(33), // Right: reserved for future use 254 | ] 255 | ); 256 | 257 | // Help text on left 258 | let help_text = Paragraph::new("Press '?' for help") 259 | .style(Style::default().fg(Color::Gray)) 260 | .alignment(Alignment::Center); 261 | f.render_widget(help_text, header_chunks[0]); 262 | 263 | // Title in center 264 | let title = Paragraph::new("Install NixOS") 265 | .style(Style::default().add_modifier(Modifier::BOLD)) 266 | .alignment(Alignment::Center); 267 | f.render_widget(title, header_chunks[1]); 268 | 269 | // Render the current page (top of the navigation stack) 270 | if let Some(page) = page_stack.last_mut() { 271 | page.render(&mut installer, f, chunks[1]); 272 | } 273 | })?; 274 | 275 | // Check if the current page has sent any signals 276 | // Signals control navigation, installation, and application lifecycle 277 | if let Some(page) = page_stack.last() 278 | && let Some(signal) = page.signal() 279 | && handle_signal(signal, &mut page_stack, &mut installer)? 280 | { 281 | // handle_signal returned true, meaning we should quit 282 | break; 283 | } 284 | 285 | // Calculate remaining time until next tick 286 | let timeout = tick_rate 287 | .checked_sub(last_tick.elapsed()) 288 | .unwrap_or_else(|| Duration::from_secs(0)); 289 | 290 | // Wait for user input or timeout 291 | if event::poll(timeout)? 292 | && let Event::Key(key) = event::read()? { 293 | if let Some(page) = page_stack.last_mut() { 294 | // Forward keyboard input to the current page 295 | let signal = page.handle_input(&mut installer, key); 296 | 297 | if handle_signal(signal, &mut page_stack, &mut installer)? { 298 | // Page requested application quit 299 | break; 300 | } 301 | } else { 302 | // Safety fallback: if no pages exist, return to main menu 303 | page_stack.push(Box::new(Menu::new())); 304 | } 305 | } 306 | 307 | if last_tick.elapsed() >= tick_rate { 308 | last_tick = Instant::now(); 309 | } 310 | } 311 | 312 | Ok(()) 313 | } 314 | -------------------------------------------------------------------------------- /src/nixgen.rs: -------------------------------------------------------------------------------- 1 | use serde_json::{Map, Value}; 2 | use std::process::{Command, Stdio}; 3 | 4 | use crate::{attrset, installer::users::User, merge_attrs}; 5 | 6 | /// Convert a value to a properly quoted Nix string literal 7 | /// 8 | /// This helper function ensures proper escaping and quoting for Nix syntax. 9 | /// Much cleaner than manually writing format!("\"{string}\"") everywhere. 10 | pub fn nixstr(val: impl ToString) -> String { 11 | let val = val.to_string(); 12 | format!("\"{val}\"") 13 | } 14 | /// Format Nix code using the nixfmt tool for proper indentation and style 15 | /// 16 | /// Assumes nixfmt is available in the environment (provided by the Nix flake) 17 | pub fn fmt_nix(nix: String) -> anyhow::Result { 18 | // Spawn nixfmt process with piped input/output for formatting 19 | let mut nixfmt_child = Command::new("nixfmt") 20 | .stdin(Stdio::piped()) 21 | .stdout(Stdio::piped()) 22 | .spawn()?; 23 | 24 | // Send the unformatted Nix code to nixfmt's stdin 25 | if let Some(stdin) = nixfmt_child.stdin.as_mut() { 26 | use std::io::Write; 27 | stdin.write_all(nix.as_bytes())?; 28 | } 29 | 30 | // Wait for nixfmt to complete and capture the formatted output 31 | let output = nixfmt_child.wait_with_output()?; 32 | if output.status.success() { 33 | let formatted = String::from_utf8(output.stdout)?; 34 | Ok(formatted) 35 | } else { 36 | let err = String::from_utf8_lossy(&output.stderr); 37 | Err(anyhow::anyhow!("nixfmt failed: {}", err)) 38 | } 39 | } 40 | /// Add syntax highlighting to Nix code using the bat tool 41 | /// 42 | /// Useful for displaying formatted Nix configurations in the UI 43 | pub fn highlight_nix(nix: &str) -> anyhow::Result { 44 | // Spawn bat with Nix syntax highlighting 45 | let mut bat_child = Command::new("bat") 46 | .arg("-p") // Plain output (no line numbers) 47 | .arg("-f") // Force colored output 48 | .arg("-l") 49 | .arg("nix") // Use Nix syntax highlighting 50 | .stdin(Stdio::piped()) 51 | .stdout(Stdio::piped()) 52 | .spawn()?; 53 | if let Some(stdin) = bat_child.stdin.as_mut() { 54 | use std::io::Write; 55 | stdin.write_all(nix.as_bytes())?; 56 | } 57 | 58 | let output = bat_child.wait_with_output()?; 59 | if output.status.success() { 60 | let highlighted = String::from_utf8(output.stdout)?; 61 | Ok(highlighted) 62 | } else { 63 | let err = String::from_utf8_lossy(&output.stderr); 64 | Err(anyhow::anyhow!("bat failed: {}", err)) 65 | } 66 | } 67 | // Example JSON configuration structure that this module processes: 68 | // { 69 | // "config": { 70 | // "audio_backend": "PulseAudio", 71 | // "bootloader": "systemd-boot", 72 | // "desktop_environment": "KDE Plasma", 73 | // "hostname": "hostname", 74 | // "kernels": ["linux"], 75 | // "keyboard_layout": "us", 76 | // "locale": "en_US.UTF-8", 77 | // "network_backend": "NetworkManager", 78 | // "timezone": "America/New_York", 79 | // "users": [...], 80 | // "system_pkgs": [...] 81 | // }, 82 | // "disko": { ... } 83 | // } 84 | /// Container for generated NixOS configuration files 85 | #[derive(Debug)] 86 | pub struct Configs { 87 | pub system: String, // NixOS system configuration 88 | pub disko: String, // Disk partitioning configuration 89 | pub flake_path: Option, // Optional flake path for advanced users 90 | } 91 | 92 | /// Converts JSON configuration to NixOS configuration files 93 | /// 94 | /// Takes structured configuration data and generates: 95 | /// - NixOS system configuration (configuration.nix) 96 | /// - Disko disk partitioning configuration 97 | pub struct NixWriter { 98 | config: Value, // JSON configuration from the installer UI 99 | } 100 | 101 | impl NixWriter { 102 | pub fn new(config: Value) -> Self { 103 | Self { config } 104 | } 105 | /// Generate both system and disko configurations from the JSON config 106 | pub fn write_configs(&self) -> anyhow::Result { 107 | // Generate disko (disk partitioning) configuration 108 | let disko = { 109 | let config = self.config["disko"].clone(); 110 | self.write_disko_config(config)? 111 | }; 112 | 113 | // Generate NixOS system configuration 114 | let sys_cfg = { 115 | let config = self.config["config"].clone(); 116 | self.write_sys_config(config)? 117 | }; 118 | 119 | // Extract optional flake path for advanced users 120 | let flake_path = self 121 | .config 122 | .get("flake_path") 123 | .and_then(|v| v.as_str().map(|s| s.to_string())); 124 | 125 | Ok(Configs { 126 | system: sys_cfg, 127 | disko, 128 | flake_path, 129 | }) 130 | } 131 | /// Generate the main NixOS system configuration (configuration.nix) 132 | /// 133 | /// Processes each configuration option and converts it to appropriate Nix 134 | /// syntax 135 | pub fn write_sys_config(&self, config: Value) -> anyhow::Result { 136 | // Ensure we have a valid JSON object to work with 137 | let Value::Object(ref cfg) = config else { 138 | return Err(anyhow::anyhow!("Config must be a JSON object")); 139 | }; 140 | 141 | let mut cfg_attrs = String::from("{}"); // Start with empty attribute set 142 | let mut install_home_manager = false; // Track if home-manager is needed 143 | // Process each configuration key and generate corresponding Nix attributes 144 | for (key, value) in cfg.iter() { 145 | log::debug!("Processing config key: {key}"); 146 | log::debug!("Config value: {value}"); 147 | 148 | // Match configuration keys to their Nix configuration generators 149 | let parsed_config = match key.trim().to_lowercase().as_str() { 150 | "audio_backend" => value.as_str().map(Self::parse_audio), 151 | "bootloader" => { 152 | // Bootloader parsing can fail, so handle errors explicitly 153 | let res = value.as_str().map(Self::parse_bootloader); 154 | match res { 155 | Some(Ok(cfg)) => Some(cfg), 156 | Some(Err(e)) => return Err(e), 157 | None => None, 158 | } 159 | } 160 | "desktop_environment" => value.as_str().map(Self::parse_desktop_environment), 161 | "enable_flakes" => value 162 | .as_bool() 163 | .filter(|&b| b) 164 | .map(|_| Self::parse_enable_flakes()), 165 | "greeter" => None, 166 | "hostname" => value.as_str().map(Self::parse_hostname), 167 | "kernels" => value.as_array().map(Self::parse_kernels), 168 | "keyboard_layout" => value.as_str().map(Self::parse_kb_layout), 169 | "locale" => value.as_str().map(Self::parse_locale), 170 | "network_backend" => value.as_str().map(Self::parse_network_backend), 171 | "profile" => None, 172 | "root_passwd_hash" => Some(Self::parse_root_pass_hash(value)?), 173 | "ssh_config" => value.as_object().and_then(Self::parse_ssh_config), 174 | "system_pkgs" => value.as_array().map(Self::parse_system_packages), 175 | "timezone" => value.as_str().map(Self::parse_timezone), 176 | "use_swap" => value.as_bool().filter(|&b| b).map(|_| Self::parse_swap()), 177 | "users" => { 178 | // Parse user configurations and check if home-manager is needed 179 | let users: Vec = serde_json::from_value(value.clone())?; 180 | install_home_manager = users.iter().any(|user| user.home_manager_cfg.is_some()); 181 | Some(self.parse_users(users)?) 182 | } 183 | _ => { 184 | log::warn!("Unknown configuration key '{key}' - skipping"); 185 | None 186 | } 187 | }; 188 | 189 | // Merge the generated configuration into the main attribute set 190 | if let Some(config) = parsed_config { 191 | cfg_attrs = merge_attrs!(cfg_attrs, config); 192 | } 193 | } 194 | // Set up imports based on whether home-manager is needed 195 | let imports = if install_home_manager { 196 | String::from( 197 | r#"{imports = [ (import "${home-manager}/nixos") ./hardware-configuration.nix ];}"#, 198 | ) 199 | } else { 200 | String::from("{imports = [./hardware-configuration.nix];}") 201 | }; 202 | 203 | // Set the NixOS state version (required for all configurations) 204 | let state_version = attrset! { 205 | "system.stateVersion" = nixstr("25.11"); 206 | }; 207 | 208 | // Combine all configuration attributes 209 | cfg_attrs = merge_attrs!(imports, cfg_attrs, state_version); 210 | 211 | // Build let-binding declarations for external dependencies 212 | let mut let_statement_declarations = vec![]; 213 | // Add home-manager dependency if any users need it 214 | if install_home_manager { 215 | let_statement_declarations.push( 216 | "home-manager = builtins.fetchTarball https://github.com/nix-community/home-manager/archive/release-25.05.tar.gz;" 217 | ) 218 | } 219 | 220 | // Construct the let-in statement if we have dependencies 221 | let let_stmt = if !let_statement_declarations.is_empty() { 222 | let joined_stmts = let_statement_declarations.join(" "); 223 | format!("let {joined_stmts} in ") 224 | } else { 225 | "".to_string() 226 | }; 227 | 228 | // Generate the final Nix function and format it 229 | let raw = if install_home_manager { 230 | format!("{{ config, pkgs, ... }}: {let_stmt} {cfg_attrs}") 231 | } else { 232 | format!("{{ config, pkgs, ... }}: {cfg_attrs}") 233 | }; 234 | 235 | // Format the generated Nix code for readability 236 | fmt_nix(raw) 237 | } 238 | /// Generate Disko configuration for disk partitioning 239 | /// 240 | /// Converts the disk layout into Disko's declarative partition format 241 | pub fn write_disko_config(&self, config: Value) -> anyhow::Result { 242 | log::debug!("Writing Disko config: {config}"); 243 | 244 | // Extract basic disk information 245 | let device = config["device"].as_str().unwrap_or("/dev/sda"); 246 | let disk_type = config["type"].as_str().unwrap_or("disk"); 247 | let content = Self::parse_disko_content(&config["content"])?; 248 | 249 | let disko_config = attrset! { 250 | "device" = nixstr(device); 251 | "type" = nixstr(disk_type); 252 | "content" = content; 253 | }; 254 | 255 | let raw = format!("{{ disko.devices.disk.main = {disko_config}; }}"); 256 | fmt_nix(raw) 257 | } 258 | 259 | fn parse_root_pass_hash(content: &Value) -> anyhow::Result { 260 | let hash = content 261 | .as_str() 262 | .ok_or_else(|| anyhow::anyhow!("Root password hash must be a string"))?; 263 | Ok(attrset! { 264 | "users.users.root.hashedPassword" = nixstr(hash); 265 | }) 266 | } 267 | 268 | /// Parse the disk content structure for Disko 269 | /// 270 | /// Processes partition definitions and filesystem configurations 271 | fn parse_disko_content(content: &Value) -> anyhow::Result { 272 | let content_type = content["type"].as_str().unwrap_or("gpt"); 273 | let partitions = &content["partitions"]; 274 | 275 | // Process each partition definition 276 | if let Some(partitions_obj) = partitions.as_object() { 277 | let mut partition_attrs = Vec::new(); 278 | 279 | for (name, partition) in partitions_obj { 280 | let partition_config = Self::parse_partition(partition)?; 281 | partition_attrs.push(format!("{} = {};", nixstr(name), partition_config)); 282 | } 283 | 284 | let partitions_attr = format!("{{ {} }}", partition_attrs.join(" ")); 285 | 286 | Ok(attrset! { 287 | "type" = nixstr(content_type); 288 | "partitions" = partitions_attr; 289 | }) 290 | } else { 291 | Ok(attrset! { 292 | "type" = nixstr(content_type); 293 | }) 294 | } 295 | } 296 | 297 | fn parse_partition(partition: &Value) -> anyhow::Result { 298 | let format = partition["format"] 299 | .as_str() 300 | .ok_or_else(|| anyhow::anyhow!("Missing required 'format' field in partition"))?; 301 | let mountpoint = partition["mountpoint"] 302 | .as_str() 303 | .ok_or_else(|| anyhow::anyhow!("Missing required 'mountpoint' field in partition"))?; 304 | let size = partition["size"] 305 | .as_str() 306 | .ok_or_else(|| anyhow::anyhow!("Missing required 'size' field in partition"))?; 307 | let part_type = partition.get("type").and_then(|v| v.as_str()); 308 | log::debug!( 309 | "Parsing partition: format={format}, mountpoint={mountpoint}, size={size}, type={part_type:?}" 310 | ); 311 | 312 | if let Some(part_type) = part_type { 313 | Ok(attrset! { 314 | type = nixstr(part_type); 315 | size = nixstr(size); 316 | content = attrset! { 317 | type = nixstr("filesystem"); 318 | format = nixstr(format); 319 | mountpoint = nixstr(mountpoint); 320 | }; 321 | }) 322 | } else { 323 | Ok(attrset! { 324 | size = nixstr(size); 325 | content = attrset! { 326 | type = nixstr("filesystem"); 327 | format = nixstr(format); 328 | mountpoint = nixstr(mountpoint); 329 | }; 330 | }) 331 | } 332 | } 333 | fn parse_ssh_config(value: &Map) -> Option { 334 | /* 335 | The SshCfg struct has these fields: 336 | - enable: bool → services.openssh.enable 337 | - port: u16 → services.openssh.ports 338 | - password_auth: bool → services.openssh.settings.PasswordAuthentication 339 | - root_login: bool → services.openssh.settings.PermitRootLogin 340 | 341 | With default values of: 342 | - enable: false 343 | - port: 22 344 | - password_auth: true 345 | - root_login: false 346 | { 347 | # SSH Configuration 348 | services.openssh = { 349 | enable = true; # corresponds to SshCfg.enable 350 | ports = [ 2222 ]; # corresponds to SshCfg.port 351 | (default 22) 352 | settings = { 353 | PasswordAuthentication = true; # corresponds to 354 | SshCfg.password_auth 355 | PermitRootLogin = "yes"; # corresponds to 356 | SshCfg.root_login 357 | }; 358 | }; 359 | } 360 | */ 361 | let enable = value["enable"].as_bool().unwrap_or(false); 362 | if !enable { 363 | return None; 364 | } 365 | let port = value["port"].as_u64().unwrap_or(22) as u16; 366 | let password_auth = value["password_auth"].as_bool().unwrap_or(true); 367 | let root_login = value["root_login"].as_bool().unwrap_or(false); 368 | let root_login_option = match root_login { 369 | true => "yes".to_string(), 370 | false => "no".to_string(), 371 | }; 372 | 373 | let options = attrset! { 374 | enable = enable; 375 | ports = format!("[{}]", port); 376 | settings = attrset! { 377 | PasswordAuthentication = password_auth; 378 | PermitRootLogin = nixstr(root_login_option); 379 | }; 380 | }; 381 | 382 | Some(format!("{{ services.openssh = {options}; }}")) 383 | } 384 | fn parse_timezone(value: &str) -> String { 385 | attrset! { 386 | "time.timeZone" = nixstr(value); 387 | } 388 | } 389 | pub fn parse_network_backend(value: &str) -> String { 390 | match value.to_lowercase().as_str() { 391 | "networkmanager" => attrset! { 392 | "networking.networkmanager.enable" = true; 393 | }, 394 | "wpa_supplicant" => attrset! { 395 | "networking.wireless.enable" = true; 396 | }, 397 | "systemd-networkd" => attrset! { 398 | "networking.useNetworkd" = true; 399 | "systemd.network.enable" = true; 400 | }, 401 | _ => String::new(), 402 | } 403 | } 404 | pub fn parse_locale(value: &str) -> String { 405 | attrset! { 406 | "i18n.defaultLocale" = nixstr(value); 407 | } 408 | } 409 | fn parse_kb_layout(value: &str) -> String { 410 | let (xkb, console) = match value { 411 | "us(qwerty)" => ("us", "us"), 412 | "us(dvorak)" => ("us", "dvorak"), 413 | "us(colemak)" => ("us", "colemak"), 414 | "uk" => ("gb", "uk"), 415 | "de" => ("de", "de"), 416 | "fr" => ("fr", "fr"), 417 | "es" => ("es", "es"), 418 | "it" => ("it", "it"), 419 | "ru" => ("ru", "ru"), 420 | "cn" => ("cn", "us"), 421 | "jp" => ("jp", "us"), 422 | "kr" => ("kr", "us"), 423 | "in" => ("in", "us"), 424 | "br" => ("br", "br-abnt2"), 425 | "nl" => ("nl", "nl"), 426 | "se" => ("se", "us"), 427 | "no" => ("no", "no"), 428 | "fi" => ("fi", "fi"), 429 | "dk" => ("dk", "dk"), 430 | "pl" => ("pl", "pl"), 431 | "tr" => ("tr", "trq"), 432 | "gr" => ("gr", "gr"), 433 | _ => ("us", "us"), 434 | }; 435 | 436 | attrset! { 437 | "services.xserver.xkb.layout" = nixstr(xkb); 438 | "console.keyMap" = nixstr(console); 439 | } 440 | } 441 | 442 | #[allow(clippy::ptr_arg)] 443 | fn parse_kernels(kernels: &Vec) -> String { 444 | if kernels.is_empty() { 445 | return String::from("{}"); 446 | } 447 | 448 | // Take the first kernel as the primary one 449 | if let Some(Value::String(kernel)) = kernels.first() { 450 | let kernel_pkg = match kernel.to_lowercase().as_str() { 451 | "linux" => "pkgs.linuxPackages", 452 | "linux_zen" => "pkgs.linuxPackages_zen", 453 | "linux_hardened" => "pkgs.linuxPackages_hardened", 454 | "linux_lts" => "pkgs.linuxPackages_lts", 455 | _ => "pkgs.linuxPackages", // Default fallback 456 | }; 457 | attrset! { 458 | "boot.kernelPackages" = kernel_pkg; 459 | } 460 | } else { 461 | String::from("{}") 462 | } 463 | } 464 | fn parse_hostname(value: &str) -> String { 465 | attrset! { 466 | "networking.hostName" = nixstr(value); 467 | } 468 | } 469 | fn _parse_greeter(value: &str, de: Option<&str>) -> String { 470 | match value.to_lowercase().as_str() { 471 | "sddm" => { 472 | if let Some(de) = de { 473 | match de { 474 | "hyprland" => attrset! { 475 | "services.displayManager.sddm" = attrset! { 476 | "wayland.enable" = true; 477 | "enable" = true; 478 | }; 479 | }, 480 | _ => attrset! { 481 | "services.displayManager.sddm.enable" = true; 482 | }, 483 | } 484 | } else { 485 | attrset! { 486 | "services.displayManager.sddm.enable" = true; 487 | } 488 | } 489 | } 490 | "gdm" => attrset! { 491 | "services.xserver.displayManager.gdm.enable" = true; 492 | }, 493 | "lightdm" => attrset! { 494 | "services.xserver.displayManager.lightdm.enable" = true; 495 | }, 496 | _ => String::new(), 497 | } 498 | } 499 | fn parse_desktop_environment(value: &str) -> String { 500 | match value.to_lowercase().as_str() { 501 | "gnome" => attrset! { 502 | "services.xserver.enable" = true; 503 | "services.xserver.desktopManager.gnome.enable" = true; 504 | }, 505 | "hyprland" => attrset! { 506 | "programs.hyprland.enable" = true; 507 | }, 508 | "plasma" | "kde plasma" => attrset! { 509 | "services.xserver.enable" = true; 510 | "services.xserver.desktopManager.plasma5.enable" = true; 511 | }, 512 | "xfce" => attrset! { 513 | "services.xserver.enable" = true; 514 | "services.xserver.desktopManager.xfce.enable" = true; 515 | }, 516 | "cinnamon" => attrset! { 517 | "services.xserver.enable" = true; 518 | "services.xserver.desktopManager.cinnamon.enable" = true; 519 | }, 520 | "mate" => attrset! { 521 | "services.xserver.enable" = true; 522 | "services.xserver.desktopManager.mate.enable" = true; 523 | }, 524 | "lxqt" => attrset! { 525 | "services.xserver.enable" = true; 526 | "services.xserver.desktopManager.lxqt.enable" = true; 527 | }, 528 | "budgie" => attrset! { 529 | "services.xserver.enable" = true; 530 | "services.xserver.desktopManager.budgie.enable" = true; 531 | }, 532 | "i3" => attrset! { 533 | "services.xserver.enable" = true; 534 | "services.xserver.windowManager.i3.enable" = true; 535 | }, 536 | _ => String::new(), 537 | } 538 | } 539 | fn parse_audio(value: &str) -> String { 540 | match value.to_lowercase().as_str() { 541 | "pulseaudio" => attrset! { 542 | "services.pulseaudio.enable" = true; 543 | "services.pipewire.enable" = false; 544 | }, 545 | "pipewire" => attrset! { 546 | "services.pipewire.enable" = true; 547 | }, 548 | _ => String::new(), 549 | } 550 | } 551 | fn parse_bootloader(value: &str) -> anyhow::Result { 552 | let bootloader_attrs = match value.to_lowercase().as_str() { 553 | "systemd-boot" => attrset! { 554 | "systemd-boot.enable" = true; 555 | "efi.canTouchEfiVariables" = true; 556 | }, 557 | 558 | "grub" => attrset! { 559 | grub = attrset! { 560 | device = nixstr("nodev"); 561 | enable = true; 562 | efiSupport = true; 563 | }; 564 | "efi.canTouchEfiVariables" = true; 565 | }, 566 | _ => String::new(), 567 | }; 568 | Ok(attrset! { 569 | "boot.loader" = bootloader_attrs; 570 | }) 571 | } 572 | 573 | fn parse_users(&self, users: Vec) -> anyhow::Result { 574 | if users.is_empty() { 575 | return Ok(String::from("{}")); 576 | } 577 | 578 | let mut user_configs = Vec::new(); 579 | let mut hm_configs = Vec::new(); 580 | 581 | for user in users { 582 | let groups_list = if user.groups.is_empty() { 583 | "[]".to_string() 584 | } else { 585 | let group_strings: Vec = user.groups.iter().map(nixstr).collect(); 586 | format!("[ {} ]", group_strings.join(" ")) 587 | }; 588 | let user_config = attrset! { 589 | "isNormalUser" = "true"; 590 | "extraGroups" = groups_list; 591 | "hashedPassword" = nixstr(user.password_hash); 592 | }; 593 | user_configs.push(format!("\"{}\" = {};", user.username, user_config)); 594 | 595 | if let Some(cfg) = user.home_manager_cfg { 596 | let pkg_list = if cfg.packages.is_empty() { 597 | "with pkgs; []".to_string() 598 | } else { 599 | let pkgs: Vec = cfg.packages.iter().map(|s| s.to_string()).collect(); 600 | format!("with pkgs; [ {} ]", pkgs.join(" ")) 601 | }; 602 | let hm_config_body = attrset! { 603 | home = attrset! { 604 | packages = pkg_list; 605 | stateVersion = nixstr("24.05"); 606 | }; 607 | }; 608 | let hm_config_expr = format!("{{pkgs, ...}}: {hm_config_body}"); 609 | let user_hm_config = format!("\"{}\" = {};", user.username, hm_config_expr); 610 | hm_configs.push(user_hm_config); 611 | } 612 | } 613 | 614 | let users = if !hm_configs.is_empty() { 615 | attrset! { 616 | "users.users" = format!("{{ {} }}", user_configs.join(" ")); 617 | "home-manager.users" = format!("{{ {} }}", hm_configs.join(" ")); 618 | } 619 | } else { 620 | attrset! { 621 | "users.users" = format!("{{ {} }}", user_configs.join(" ")); 622 | } 623 | }; 624 | 625 | log::debug!("Parsed users config: {users}"); 626 | 627 | Ok(users) 628 | } 629 | 630 | #[allow(clippy::ptr_arg)] 631 | fn parse_system_packages(packages: &Vec) -> String { 632 | if packages.is_empty() { 633 | return String::from("{}"); 634 | } 635 | 636 | let pkg_list: Vec = packages 637 | .iter() 638 | .filter_map(&Value::as_str) 639 | .map(&str::to_string) 640 | .collect(); 641 | 642 | if pkg_list.is_empty() { 643 | return String::from("{}"); 644 | } 645 | 646 | let packages_attr = format!("with pkgs; [ {} ]", pkg_list.join(" ")); 647 | attrset! { 648 | "environment.systemPackages" = packages_attr; 649 | } 650 | } 651 | 652 | fn parse_enable_flakes() -> String { 653 | attrset! { 654 | "nix.settings.experimental-features" = "[ \"nix-command\" \"flakes\" ]"; 655 | } 656 | } 657 | 658 | fn parse_swap() -> String { 659 | attrset! { 660 | "swapDevices" = "[ { device = \"/swapfile\"; size = 4096; } ]"; 661 | } 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /src/installer/networking.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | crossterm::event::{KeyCode, KeyEvent}, 4 | layout::{Constraint, Direction, Layout, Rect}, 5 | style::{Color, Modifier}, 6 | text::Line, 7 | }; 8 | use serde_json::Value; 9 | 10 | use crate::{ 11 | installer::{Installer, Page, Signal, SshCfg}, 12 | split_hor, split_vert, styled_block, ui_back, ui_close, ui_down, ui_up, 13 | widget::{Button, CheckBox, ConfigWidget, HelpModal, InfoBox, LineEditor, StrList, WidgetBox}, 14 | }; 15 | 16 | const HIGHLIGHT: Option<(Color, Modifier)> = Some((Color::Yellow, Modifier::BOLD)); 17 | 18 | pub struct NetworkConfig { 19 | menu_items: StrList, 20 | help_modal: HelpModal<'static>, 21 | } 22 | 23 | impl NetworkConfig { 24 | pub fn new() -> Self { 25 | let items = vec![ 26 | "Network Backend".to_string(), 27 | "SSH Configuration".to_string(), 28 | "Back".to_string(), 29 | ]; 30 | let mut menu_items = StrList::new("Network Configuration", items); 31 | menu_items.focus(); 32 | 33 | let help_content = styled_block(vec![ 34 | vec![ 35 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 36 | (None, " - Navigate menu items"), 37 | ], 38 | vec![ 39 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 40 | (None, " - Select menu item"), 41 | ], 42 | vec![ 43 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 44 | (None, " - Return to main menu"), 45 | ], 46 | vec![ 47 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 48 | (None, " - Show this help"), 49 | ], 50 | vec![(None, "")], 51 | vec![( 52 | None, 53 | "Configure network settings including backend and SSH.", 54 | )], 55 | ]); 56 | let help_modal = HelpModal::new("Network Configuration", help_content); 57 | 58 | Self { 59 | menu_items, 60 | help_modal, 61 | } 62 | } 63 | 64 | pub fn display_widget(installer: &mut Installer) -> Option> { 65 | let mut lines = vec![]; 66 | 67 | if let Some(ref backend) = installer.network_backend { 68 | lines.push(vec![(None, "Network backend: ".into())]); 69 | lines.push(vec![(HIGHLIGHT, backend.to_string())]); 70 | } else { 71 | lines.push(vec![(None, "Network backend: ".into())]); 72 | lines.push(vec![(HIGHLIGHT, "Not configured".into())]); 73 | } 74 | 75 | lines.push(vec![(None, "".into())]); 76 | 77 | if let Some(ref ssh) = installer.ssh_config { 78 | if ssh.enable { 79 | lines.push(vec![(None, "SSH: ".into())]); 80 | lines.push(vec![(HIGHLIGHT, "Enabled".into())]); 81 | lines.push(vec![ 82 | (None, "Port: ".into()), 83 | ( 84 | HIGHLIGHT, 85 | installer 86 | .ssh_config 87 | .as_ref() 88 | .map(|cfg| cfg.port) 89 | .unwrap_or(22) 90 | .to_string(), 91 | ), // Use static string for now 92 | ]); 93 | } else { 94 | lines.push(vec![(None, "SSH: ".into())]); 95 | lines.push(vec![(HIGHLIGHT, "Disabled".into())]); 96 | } 97 | } else { 98 | lines.push(vec![(None, "SSH: ".into())]); 99 | lines.push(vec![(HIGHLIGHT, "Not configured".into())]); 100 | } 101 | 102 | let ib = InfoBox::new("", styled_block(lines)); 103 | Some(Box::new(ib) as Box) 104 | } 105 | 106 | pub fn page_info<'a>() -> (String, Vec>) { 107 | ( 108 | "Network".to_string(), 109 | styled_block(vec![ 110 | vec![(None, "Configure network settings for your system.")], 111 | vec![( 112 | None, 113 | "This includes selecting a network backend and configuring SSH access.", 114 | )], 115 | ]), 116 | ) 117 | } 118 | } 119 | 120 | impl Default for NetworkConfig { 121 | fn default() -> Self { 122 | Self::new() 123 | } 124 | } 125 | 126 | impl Page for NetworkConfig { 127 | fn render(&mut self, installer: &mut Installer, f: &mut Frame, area: Rect) { 128 | let chunks = split_vert!( 129 | area, 130 | 1, 131 | [Constraint::Percentage(40), Constraint::Percentage(60)] 132 | ); 133 | 134 | let hor_chunks = split_hor!( 135 | chunks[1], 136 | 1, 137 | [ 138 | Constraint::Percentage(30), 139 | Constraint::Percentage(40), 140 | Constraint::Percentage(30), 141 | ] 142 | ); 143 | 144 | // Info box showing current configuration 145 | let mut info_lines = vec![ 146 | vec![(HIGHLIGHT, "Current Network Configuration".to_string())], 147 | vec![(None, "".into())], 148 | ]; 149 | 150 | if let Some(ref backend) = installer.network_backend { 151 | info_lines.push(vec![ 152 | (None, "Network Backend: ".into()), 153 | (HIGHLIGHT, backend.to_string()), 154 | ]); 155 | } else { 156 | info_lines.push(vec![ 157 | (None, "Network Backend: ".into()), 158 | (None, "Not configured".into()), 159 | ]); 160 | } 161 | 162 | if let Some(ref ssh) = installer.ssh_config { 163 | if ssh.enable { 164 | info_lines.push(vec![ 165 | (None, "SSH Server: ".into()), 166 | (HIGHLIGHT, "Enabled".into()), 167 | ]); 168 | info_lines.push(vec![ 169 | (None, " Port: ".into()), 170 | (HIGHLIGHT, ssh.port.to_string()), // Use static string for now 171 | ]); 172 | info_lines.push(vec![ 173 | (None, " Password Auth: ".into()), 174 | ( 175 | HIGHLIGHT, 176 | if ssh.password_auth { 177 | "Yes".into() 178 | } else { 179 | "No".into() 180 | }, 181 | ), 182 | ]); 183 | info_lines.push(vec![ 184 | (None, " Root Login: ".into()), 185 | ( 186 | HIGHLIGHT, 187 | if ssh.root_login { 188 | "Yes".into() 189 | } else { 190 | "No".into() 191 | }, 192 | ), 193 | ]); 194 | } else { 195 | info_lines.push(vec![ 196 | (None, "SSH Server: ".into()), 197 | (None, "Disabled".into()), 198 | ]); 199 | } 200 | } else { 201 | info_lines.push(vec![ 202 | (None, "SSH Server: ".into()), 203 | (None, "Not configured".into()), 204 | ]); 205 | } 206 | 207 | let info_box = InfoBox::new("", styled_block(info_lines)); 208 | info_box.render(f, chunks[0]); 209 | 210 | self.menu_items.render(f, hor_chunks[1]); 211 | self.help_modal.render(f, area); 212 | } 213 | 214 | fn get_help_content(&self) -> (String, Vec>) { 215 | let help_content = styled_block(vec![ 216 | vec![ 217 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 218 | (None, " - Navigate menu items"), 219 | ], 220 | vec![ 221 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 222 | (None, " - Select menu item"), 223 | ], 224 | vec![ 225 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 226 | (None, " - Return to main menu"), 227 | ], 228 | vec![ 229 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 230 | (None, " - Show this help"), 231 | ], 232 | vec![(None, "")], 233 | vec![( 234 | None, 235 | "Configure network settings including backend and SSH.", 236 | )], 237 | ]); 238 | ("Network Configuration".to_string(), help_content) 239 | } 240 | 241 | fn handle_input(&mut self, _installer: &mut Installer, event: KeyEvent) -> Signal { 242 | match event.code { 243 | KeyCode::Char('?') => { 244 | self.help_modal.toggle(); 245 | Signal::Wait 246 | } 247 | ui_close!() if self.help_modal.visible => { 248 | self.help_modal.hide(); 249 | Signal::Wait 250 | } 251 | _ if self.help_modal.visible => Signal::Wait, 252 | ui_back!() => Signal::Pop, 253 | KeyCode::Enter => { 254 | match self.menu_items.selected_idx { 255 | 0 => Signal::Push(Box::new(NetworkBackend::new())), 256 | 1 => Signal::Push(Box::new(SshConfig::new())), 257 | 2 => Signal::Pop, // Back 258 | _ => Signal::Wait, 259 | } 260 | } 261 | ui_up!() => { 262 | if !self.menu_items.previous_item() { 263 | self.menu_items.last_item(); 264 | } 265 | Signal::Wait 266 | } 267 | ui_down!() => { 268 | if !self.menu_items.next_item() { 269 | self.menu_items.first_item(); 270 | } 271 | Signal::Wait 272 | } 273 | _ => self.menu_items.handle_input(event), 274 | } 275 | } 276 | } 277 | 278 | // Network Backend selection page (same as before) 279 | pub struct NetworkBackend { 280 | backends: StrList, 281 | help_modal: HelpModal<'static>, 282 | } 283 | 284 | impl NetworkBackend { 285 | pub fn new() -> Self { 286 | let backends = [ 287 | "NetworkManager", 288 | "wpa_supplicant", 289 | "systemd-networkd", 290 | "None", 291 | ] 292 | .iter() 293 | .map(|s| s.to_string()) 294 | .collect::>(); 295 | let mut backends = StrList::new("Select Network Backend", backends); 296 | backends.focus(); 297 | 298 | let help_content = styled_block(vec![ 299 | vec![ 300 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 301 | (None, " - Navigate network backend options"), 302 | ], 303 | vec![ 304 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 305 | (None, " - Select network backend and return"), 306 | ], 307 | vec![ 308 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 309 | (None, " - Cancel and return"), 310 | ], 311 | vec![ 312 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 313 | (None, " - Show this help"), 314 | ], 315 | vec![(None, "")], 316 | vec![( 317 | None, 318 | "Select the network management backend for connections.", 319 | )], 320 | ]); 321 | let help_modal = HelpModal::new("Network Backend", help_content); 322 | 323 | Self { 324 | backends, 325 | help_modal, 326 | } 327 | } 328 | 329 | pub fn get_network_info<'a>(idx: usize) -> InfoBox<'a> { 330 | match idx { 331 | 0 => InfoBox::new( 332 | "NetworkManager", 333 | styled_block(vec![ 334 | vec![ 335 | (HIGHLIGHT, "NetworkManager"), 336 | (None, " is a "), 337 | (HIGHLIGHT, "comprehensive network management daemon"), 338 | (None, " that provides "), 339 | (HIGHLIGHT, "automatic network configuration"), 340 | (None, " and "), 341 | (HIGHLIGHT, "seamless connectivity management"), 342 | (None, "."), 343 | ], 344 | vec![ 345 | (None, "It supports "), 346 | (HIGHLIGHT, "WiFi, Ethernet, VPN, and mobile broadband"), 347 | (None, " connections with "), 348 | (HIGHLIGHT, "automatic switching"), 349 | (None, " between available networks."), 350 | ], 351 | vec![ 352 | (None, "NetworkManager provides "), 353 | (HIGHLIGHT, "GUI integration"), 354 | (None, " and is the "), 355 | (HIGHLIGHT, "most user-friendly option"), 356 | (None, " for desktop environments."), 357 | ], 358 | ]), 359 | ), 360 | 1 => InfoBox::new( 361 | "wpa_supplicant", 362 | styled_block(vec![ 363 | vec![ 364 | (HIGHLIGHT, "wpa_supplicant"), 365 | (None, " is a "), 366 | (HIGHLIGHT, "lightweight WiFi authentication client"), 367 | (None, " that handles "), 368 | (HIGHLIGHT, "WPA/WPA2 and WPA3 security protocols"), 369 | (None, "."), 370 | ], 371 | vec![ 372 | (None, "It provides "), 373 | (HIGHLIGHT, "minimal overhead"), 374 | (None, " and "), 375 | (HIGHLIGHT, "direct control"), 376 | (None, " over wireless connections but requires "), 377 | (HIGHLIGHT, "manual configuration"), 378 | (None, " for most setups."), 379 | ], 380 | vec![ 381 | (None, "wpa_supplicant is "), 382 | (HIGHLIGHT, "ideal for servers"), 383 | (None, " or users who prefer "), 384 | (HIGHLIGHT, "command-line network management"), 385 | (None, " with minimal dependencies."), 386 | ], 387 | ]), 388 | ), 389 | 2 => InfoBox::new( 390 | "systemd-networkd", 391 | styled_block(vec![ 392 | vec![ 393 | (HIGHLIGHT, "systemd-networkd"), 394 | (None, " is a "), 395 | (HIGHLIGHT, "systemd-native network manager"), 396 | (None, " that provides "), 397 | (HIGHLIGHT, "efficient and lightweight"), 398 | (None, " network configuration."), 399 | ], 400 | vec![ 401 | (None, "It offers "), 402 | (HIGHLIGHT, "declarative configuration"), 403 | ( 404 | None, 405 | " through configuration files and integrates well with ", 406 | ), 407 | (HIGHLIGHT, "systemd-resolved"), 408 | (None, " for DNS management."), 409 | ], 410 | vec![ 411 | (None, "systemd-networkd is "), 412 | (HIGHLIGHT, "perfect for servers"), 413 | (None, " and "), 414 | (HIGHLIGHT, "headless systems"), 415 | ( 416 | None, 417 | " but has limited support for complex desktop networking scenarios.", 418 | ), 419 | ], 420 | ]), 421 | ), 422 | _ => InfoBox::new( 423 | "No Backend", 424 | styled_block(vec![vec![( 425 | None, 426 | "No network backend will be installed. Manual network configuration will be required.", 427 | )]]), 428 | ), 429 | } 430 | } 431 | } 432 | 433 | impl Page for NetworkBackend { 434 | fn render(&mut self, _installer: &mut Installer, f: &mut Frame, area: Rect) { 435 | let vert_chunks = Layout::default() 436 | .direction(Direction::Vertical) 437 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 438 | .split(area); 439 | let hor_chunks = split_hor!( 440 | vert_chunks[0], 441 | 1, 442 | [ 443 | Constraint::Percentage(40), 444 | Constraint::Percentage(20), 445 | Constraint::Percentage(40), 446 | ] 447 | ); 448 | 449 | let idx = self.backends.selected_idx; 450 | let info_box = Self::get_network_info(idx); 451 | self.backends.render(f, hor_chunks[1]); 452 | info_box.render(f, vert_chunks[1]); 453 | 454 | self.help_modal.render(f, area); 455 | } 456 | 457 | fn get_help_content(&self) -> (String, Vec>) { 458 | let help_content = styled_block(vec![ 459 | vec![ 460 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 461 | (None, " - Navigate network backend options"), 462 | ], 463 | vec![ 464 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 465 | (None, " - Select network backend and return"), 466 | ], 467 | vec![ 468 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 469 | (None, " - Cancel and return"), 470 | ], 471 | vec![ 472 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 473 | (None, " - Show this help"), 474 | ], 475 | vec![(None, "")], 476 | vec![( 477 | None, 478 | "Select the network management backend for connections.", 479 | )], 480 | ]); 481 | ("Network Backend".to_string(), help_content) 482 | } 483 | 484 | fn handle_input(&mut self, installer: &mut Installer, event: KeyEvent) -> Signal { 485 | match event.code { 486 | KeyCode::Char('?') => { 487 | self.help_modal.toggle(); 488 | Signal::Wait 489 | } 490 | ui_close!() if self.help_modal.visible => { 491 | self.help_modal.hide(); 492 | Signal::Wait 493 | } 494 | _ if self.help_modal.visible => Signal::Wait, 495 | ui_back!() => Signal::Pop, 496 | KeyCode::Enter => { 497 | let backend = if self.backends.selected_idx == 3 { 498 | None 499 | } else { 500 | Some(self.backends.items[self.backends.selected_idx].clone()) 501 | }; 502 | installer.network_backend = backend; 503 | Signal::Pop 504 | } 505 | ui_up!() => { 506 | if !self.backends.previous_item() { 507 | self.backends.last_item(); 508 | } 509 | Signal::Wait 510 | } 511 | ui_down!() => { 512 | if !self.backends.next_item() { 513 | self.backends.first_item(); 514 | } 515 | Signal::Wait 516 | } 517 | _ => self.backends.handle_input(event), 518 | } 519 | } 520 | } 521 | 522 | // Simplified SSH Configuration page (no authorized keys) 523 | pub struct SshConfig { 524 | buttons: WidgetBox, 525 | port_input: LineEditor, 526 | help_modal: HelpModal<'static>, 527 | input_mode: SshInputMode, 528 | // State tracking 529 | enable_ssh: bool, 530 | password_auth: bool, 531 | root_login: bool, 532 | initialized: bool, 533 | } 534 | 535 | enum SshInputMode { 536 | Buttons, 537 | Port, 538 | } 539 | 540 | impl SshConfig { 541 | pub fn new() -> Self { 542 | let enable_ssh = CheckBox::new("Enable SSH", false); 543 | let password_auth = CheckBox::new("Allow Password Authentication", true); 544 | let root_login = CheckBox::new("Allow Root Login", false); 545 | let port_btn = Button::new("Configure Port"); 546 | let back_btn = Button::new("Back"); 547 | 548 | let mut buttons = WidgetBox::button_menu(vec![ 549 | Box::new(enable_ssh), 550 | Box::new(password_auth), 551 | Box::new(root_login), 552 | Box::new(port_btn), 553 | Box::new(back_btn), 554 | ]); 555 | buttons.focus(); 556 | 557 | let port_input = LineEditor::new("SSH Port", Some("Default: 22")); 558 | 559 | let help_content = styled_block(vec![ 560 | vec![ 561 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 562 | (None, " - Navigate options"), 563 | ], 564 | vec![ 565 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 566 | (None, " - Toggle option or select action"), 567 | ], 568 | vec![ 569 | (Some((Color::Yellow, Modifier::BOLD)), "Tab"), 570 | (None, " - Move to port input"), 571 | ], 572 | vec![ 573 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 574 | (None, " - Cancel and return"), 575 | ], 576 | vec![ 577 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 578 | (None, " - Show this help"), 579 | ], 580 | vec![(None, "")], 581 | vec![(None, "Configure SSH server settings for remote access.")], 582 | ]); 583 | let help_modal = HelpModal::new("SSH Configuration", help_content); 584 | 585 | Self { 586 | buttons, 587 | port_input, 588 | help_modal, 589 | input_mode: SshInputMode::Buttons, 590 | enable_ssh: false, 591 | password_auth: true, 592 | root_login: false, 593 | initialized: false, 594 | } 595 | } 596 | 597 | fn update_from_config(&mut self, installer: &Installer) { 598 | if let Some(ref cfg) = installer.ssh_config { 599 | // Update instance state 600 | self.enable_ssh = cfg.enable; 601 | self.password_auth = cfg.password_auth; 602 | self.root_login = cfg.root_login; 603 | 604 | // Update inputs 605 | self.port_input.set_value(&cfg.port.to_string()); 606 | } 607 | 608 | // Always recreate buttons with current state values 609 | let enable_ssh = CheckBox::new("Enable SSH", self.enable_ssh); 610 | let password_auth = CheckBox::new("Allow Password Authentication", self.password_auth); 611 | let root_login = CheckBox::new("Allow Root Login", self.root_login); 612 | let port_btn = Button::new("Configure Port"); 613 | let back_btn = Button::new("Back"); 614 | 615 | self.buttons.set_children_inplace(vec![ 616 | Box::new(enable_ssh), 617 | Box::new(password_auth), 618 | Box::new(root_login), 619 | Box::new(port_btn), 620 | Box::new(back_btn), 621 | ]); 622 | 623 | // Ensure buttons are focused 624 | self.buttons.focus(); 625 | } 626 | 627 | fn save_to_config(&self, installer: &mut Installer) { 628 | let port = self 629 | .port_input 630 | .get_value() 631 | .and_then(|v| { 632 | if let Value::String(s) = v { 633 | Some(s) 634 | } else { 635 | None 636 | } 637 | }) 638 | .and_then(|s| s.parse::().ok()) 639 | .unwrap_or(22); 640 | 641 | installer.ssh_config = Some(SshCfg { 642 | enable: self.enable_ssh, 643 | port, 644 | password_auth: self.password_auth, 645 | root_login: self.root_login, 646 | }); 647 | } 648 | } 649 | 650 | impl Page for SshConfig { 651 | fn render(&mut self, installer: &mut Installer, f: &mut Frame, area: Rect) { 652 | // Only update config on first render 653 | if !self.initialized { 654 | self.update_from_config(installer); 655 | self.initialized = true; 656 | } 657 | 658 | let chunks = split_vert!( 659 | area, 660 | 1, 661 | [Constraint::Percentage(30), Constraint::Percentage(70)] 662 | ); 663 | 664 | // Info box 665 | let info_lines = vec![ 666 | vec![( 667 | None, 668 | "Configure SSH server for secure remote access to your system.", 669 | )], 670 | vec![(None, "")], 671 | vec![( 672 | None, 673 | "SSH (Secure Shell) allows encrypted remote login and command execution.", 674 | )], 675 | vec![(None, "")], 676 | vec![(HIGHLIGHT, "Security Recommendations:")], 677 | vec![(None, "• Use key-based authentication when possible")], 678 | vec![(None, "• Disable root login for better security")], 679 | vec![(None, "• Consider changing the default port")], 680 | ]; 681 | 682 | let info_box = InfoBox::new("SSH Server", styled_block(info_lines)); 683 | info_box.render(f, chunks[0]); 684 | 685 | // Controls area 686 | match self.input_mode { 687 | SshInputMode::Buttons => { 688 | let hor_chunks = split_hor!( 689 | chunks[1], 690 | 1, 691 | [ 692 | Constraint::Percentage(25), 693 | Constraint::Percentage(50), 694 | Constraint::Percentage(25), 695 | ] 696 | ); 697 | self.buttons.render(f, hor_chunks[1]); 698 | } 699 | SshInputMode::Port => { 700 | let input_chunks = split_hor!( 701 | chunks[1], 702 | 1, 703 | [ 704 | Constraint::Percentage(25), 705 | Constraint::Percentage(50), 706 | Constraint::Percentage(25), 707 | ] 708 | ); 709 | self.port_input.render(f, input_chunks[1]); 710 | } 711 | } 712 | 713 | self.help_modal.render(f, area); 714 | } 715 | 716 | fn get_help_content(&self) -> (String, Vec>) { 717 | let help_content = styled_block(vec![ 718 | vec![ 719 | (Some((Color::Yellow, Modifier::BOLD)), "↑/↓, j/k"), 720 | (None, " - Navigate options"), 721 | ], 722 | vec![ 723 | (Some((Color::Yellow, Modifier::BOLD)), "Enter"), 724 | (None, " - Toggle option or select action"), 725 | ], 726 | vec![ 727 | (Some((Color::Yellow, Modifier::BOLD)), "Tab"), 728 | (None, " - Move to port input"), 729 | ], 730 | vec![ 731 | (Some((Color::Yellow, Modifier::BOLD)), "Esc, q, ←, h"), 732 | (None, " - Cancel and return"), 733 | ], 734 | vec![ 735 | (Some((Color::Yellow, Modifier::BOLD)), "?"), 736 | (None, " - Show this help"), 737 | ], 738 | vec![(None, "")], 739 | vec![(None, "Configure SSH server settings for remote access.")], 740 | ]); 741 | ("SSH Configuration".to_string(), help_content) 742 | } 743 | 744 | fn handle_input(&mut self, installer: &mut Installer, event: KeyEvent) -> Signal { 745 | match event.code { 746 | KeyCode::Char('?') => { 747 | self.help_modal.toggle(); 748 | Signal::Wait 749 | } 750 | ui_close!() if self.help_modal.visible => { 751 | self.help_modal.hide(); 752 | Signal::Wait 753 | } 754 | _ if self.help_modal.visible => Signal::Wait, 755 | ui_back!() => match self.input_mode { 756 | SshInputMode::Port => { 757 | self.input_mode = SshInputMode::Buttons; 758 | self.port_input.unfocus(); 759 | self.buttons.focus(); 760 | Signal::Wait 761 | } 762 | SshInputMode::Buttons => { 763 | self.save_to_config(installer); 764 | Signal::Pop 765 | } 766 | }, 767 | _ => { 768 | match self.input_mode { 769 | SshInputMode::Buttons => { 770 | match event.code { 771 | ui_up!() => { 772 | self.buttons.prev_child(); 773 | Signal::Wait 774 | } 775 | ui_down!() => { 776 | self.buttons.next_child(); 777 | Signal::Wait 778 | } 779 | KeyCode::Enter => { 780 | match self.buttons.selected_child() { 781 | Some(0) => { 782 | // Toggle Enable SSH checkbox 783 | if let Some(checkbox) = self.buttons.focused_child_mut() { 784 | checkbox.interact(); 785 | if let Some(Value::Bool(enabled)) = checkbox.get_value() { 786 | self.enable_ssh = enabled; 787 | } 788 | } 789 | Signal::Wait 790 | } 791 | Some(1) => { 792 | // Toggle Password Auth checkbox 793 | if let Some(checkbox) = self.buttons.focused_child_mut() { 794 | checkbox.interact(); 795 | if let Some(Value::Bool(enabled)) = checkbox.get_value() { 796 | self.password_auth = enabled; 797 | } 798 | } 799 | Signal::Wait 800 | } 801 | Some(2) => { 802 | // Toggle Root Login checkbox 803 | if let Some(checkbox) = self.buttons.focused_child_mut() { 804 | checkbox.interact(); 805 | if let Some(Value::Bool(enabled)) = checkbox.get_value() { 806 | self.root_login = enabled; 807 | } 808 | } 809 | Signal::Wait 810 | } 811 | Some(3) => { 812 | // Configure Port 813 | self.input_mode = SshInputMode::Port; 814 | self.buttons.unfocus(); 815 | self.port_input.focus(); 816 | Signal::Wait 817 | } 818 | Some(4) => { 819 | // Back button 820 | self.save_to_config(installer); 821 | Signal::Pop 822 | } 823 | _ => Signal::Wait, 824 | } 825 | } 826 | _ => Signal::Wait, 827 | } 828 | } 829 | SshInputMode::Port => match event.code { 830 | KeyCode::Enter | KeyCode::Tab => { 831 | // Save the current port value and return to buttons 832 | self.input_mode = SshInputMode::Buttons; 833 | self.port_input.unfocus(); 834 | self.buttons.focus(); 835 | Signal::Wait 836 | } 837 | _ => self.port_input.handle_input(event), 838 | }, 839 | } 840 | } 841 | } 842 | } 843 | } 844 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "allocator-api2" 16 | version = "0.2.21" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 | 20 | [[package]] 21 | name = "ansi-to-tui" 22 | version = "7.0.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" 25 | dependencies = [ 26 | "nom", 27 | "ratatui", 28 | "simdutf8", 29 | "smallvec", 30 | "thiserror", 31 | ] 32 | 33 | [[package]] 34 | name = "anstream" 35 | version = "0.6.19" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" 38 | dependencies = [ 39 | "anstyle", 40 | "anstyle-parse", 41 | "anstyle-query", 42 | "anstyle-wincon", 43 | "colorchoice", 44 | "is_terminal_polyfill", 45 | "utf8parse", 46 | ] 47 | 48 | [[package]] 49 | name = "anstyle" 50 | version = "1.0.11" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 53 | 54 | [[package]] 55 | name = "anstyle-parse" 56 | version = "0.2.7" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 59 | dependencies = [ 60 | "utf8parse", 61 | ] 62 | 63 | [[package]] 64 | name = "anstyle-query" 65 | version = "1.1.3" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" 68 | dependencies = [ 69 | "windows-sys 0.59.0", 70 | ] 71 | 72 | [[package]] 73 | name = "anstyle-wincon" 74 | version = "3.0.9" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" 77 | dependencies = [ 78 | "anstyle", 79 | "once_cell_polyfill", 80 | "windows-sys 0.59.0", 81 | ] 82 | 83 | [[package]] 84 | name = "anyhow" 85 | version = "1.0.98" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 88 | 89 | [[package]] 90 | name = "autocfg" 91 | version = "1.5.0" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 94 | 95 | [[package]] 96 | name = "bitflags" 97 | version = "2.9.1" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 100 | 101 | [[package]] 102 | name = "cassowary" 103 | version = "0.3.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 106 | 107 | [[package]] 108 | name = "castaway" 109 | version = "0.2.4" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 112 | dependencies = [ 113 | "rustversion", 114 | ] 115 | 116 | [[package]] 117 | name = "cfg-if" 118 | version = "1.0.1" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" 121 | 122 | [[package]] 123 | name = "cfg_aliases" 124 | version = "0.2.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 127 | 128 | [[package]] 129 | name = "colorchoice" 130 | version = "1.0.4" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 133 | 134 | [[package]] 135 | name = "compact_str" 136 | version = "0.8.1" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 139 | dependencies = [ 140 | "castaway", 141 | "cfg-if", 142 | "itoa", 143 | "rustversion", 144 | "ryu", 145 | "static_assertions", 146 | ] 147 | 148 | [[package]] 149 | name = "crossterm" 150 | version = "0.28.1" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 153 | dependencies = [ 154 | "bitflags", 155 | "crossterm_winapi", 156 | "mio", 157 | "parking_lot", 158 | "rustix 0.38.44", 159 | "signal-hook", 160 | "signal-hook-mio", 161 | "winapi", 162 | ] 163 | 164 | [[package]] 165 | name = "crossterm_winapi" 166 | version = "0.9.1" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 169 | dependencies = [ 170 | "winapi", 171 | ] 172 | 173 | [[package]] 174 | name = "darling" 175 | version = "0.20.11" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 178 | dependencies = [ 179 | "darling_core", 180 | "darling_macro", 181 | ] 182 | 183 | [[package]] 184 | name = "darling_core" 185 | version = "0.20.11" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 188 | dependencies = [ 189 | "fnv", 190 | "ident_case", 191 | "proc-macro2", 192 | "quote", 193 | "strsim", 194 | "syn", 195 | ] 196 | 197 | [[package]] 198 | name = "darling_macro" 199 | version = "0.20.11" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 202 | dependencies = [ 203 | "darling_core", 204 | "quote", 205 | "syn", 206 | ] 207 | 208 | [[package]] 209 | name = "deranged" 210 | version = "0.4.0" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 213 | dependencies = [ 214 | "powerfmt", 215 | ] 216 | 217 | [[package]] 218 | name = "either" 219 | version = "1.15.0" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 222 | 223 | [[package]] 224 | name = "env_filter" 225 | version = "0.1.3" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 228 | dependencies = [ 229 | "log", 230 | "regex", 231 | ] 232 | 233 | [[package]] 234 | name = "env_logger" 235 | version = "0.11.8" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" 238 | dependencies = [ 239 | "anstream", 240 | "anstyle", 241 | "env_filter", 242 | "jiff", 243 | "log", 244 | ] 245 | 246 | [[package]] 247 | name = "equivalent" 248 | version = "1.0.2" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 251 | 252 | [[package]] 253 | name = "errno" 254 | version = "0.3.13" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" 257 | dependencies = [ 258 | "libc", 259 | "windows-sys 0.60.2", 260 | ] 261 | 262 | [[package]] 263 | name = "fastrand" 264 | version = "2.3.0" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 267 | 268 | [[package]] 269 | name = "fnv" 270 | version = "1.0.7" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 273 | 274 | [[package]] 275 | name = "foldhash" 276 | version = "0.1.5" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 279 | 280 | [[package]] 281 | name = "fuzzy-matcher" 282 | version = "0.3.7" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" 285 | dependencies = [ 286 | "thread_local", 287 | ] 288 | 289 | [[package]] 290 | name = "getrandom" 291 | version = "0.2.16" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 294 | dependencies = [ 295 | "cfg-if", 296 | "libc", 297 | "wasi 0.11.1+wasi-snapshot-preview1", 298 | ] 299 | 300 | [[package]] 301 | name = "getrandom" 302 | version = "0.3.3" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 305 | dependencies = [ 306 | "cfg-if", 307 | "libc", 308 | "r-efi", 309 | "wasi 0.14.2+wasi-0.2.4", 310 | ] 311 | 312 | [[package]] 313 | name = "hashbrown" 314 | version = "0.15.4" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" 317 | dependencies = [ 318 | "allocator-api2", 319 | "equivalent", 320 | "foldhash", 321 | ] 322 | 323 | [[package]] 324 | name = "heck" 325 | version = "0.5.0" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 328 | 329 | [[package]] 330 | name = "ident_case" 331 | version = "1.0.1" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 334 | 335 | [[package]] 336 | name = "indoc" 337 | version = "2.0.6" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 340 | 341 | [[package]] 342 | name = "instability" 343 | version = "0.3.9" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" 346 | dependencies = [ 347 | "darling", 348 | "indoc", 349 | "proc-macro2", 350 | "quote", 351 | "syn", 352 | ] 353 | 354 | [[package]] 355 | name = "is_terminal_polyfill" 356 | version = "1.70.1" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 359 | 360 | [[package]] 361 | name = "itertools" 362 | version = "0.13.0" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 365 | dependencies = [ 366 | "either", 367 | ] 368 | 369 | [[package]] 370 | name = "itoa" 371 | version = "1.0.15" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 374 | 375 | [[package]] 376 | name = "jiff" 377 | version = "0.2.15" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" 380 | dependencies = [ 381 | "jiff-static", 382 | "log", 383 | "portable-atomic", 384 | "portable-atomic-util", 385 | "serde", 386 | ] 387 | 388 | [[package]] 389 | name = "jiff-static" 390 | version = "0.2.15" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" 393 | dependencies = [ 394 | "proc-macro2", 395 | "quote", 396 | "syn", 397 | ] 398 | 399 | [[package]] 400 | name = "libc" 401 | version = "0.2.174" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" 404 | 405 | [[package]] 406 | name = "linux-raw-sys" 407 | version = "0.4.15" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 410 | 411 | [[package]] 412 | name = "linux-raw-sys" 413 | version = "0.9.4" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 416 | 417 | [[package]] 418 | name = "lock_api" 419 | version = "0.4.13" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 422 | dependencies = [ 423 | "autocfg", 424 | "scopeguard", 425 | ] 426 | 427 | [[package]] 428 | name = "log" 429 | version = "0.4.27" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 432 | 433 | [[package]] 434 | name = "lru" 435 | version = "0.12.5" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 438 | dependencies = [ 439 | "hashbrown", 440 | ] 441 | 442 | [[package]] 443 | name = "memchr" 444 | version = "2.7.5" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 447 | 448 | [[package]] 449 | name = "minimal-lexical" 450 | version = "0.2.1" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 453 | 454 | [[package]] 455 | name = "mio" 456 | version = "1.0.4" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 459 | dependencies = [ 460 | "libc", 461 | "log", 462 | "wasi 0.11.1+wasi-snapshot-preview1", 463 | "windows-sys 0.59.0", 464 | ] 465 | 466 | [[package]] 467 | name = "nix" 468 | version = "0.30.1" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" 471 | dependencies = [ 472 | "bitflags", 473 | "cfg-if", 474 | "cfg_aliases", 475 | "libc", 476 | ] 477 | 478 | [[package]] 479 | name = "nixos-wizard" 480 | version = "0.3.2" 481 | dependencies = [ 482 | "ansi-to-tui", 483 | "anyhow", 484 | "env_logger", 485 | "fuzzy-matcher", 486 | "log", 487 | "nix", 488 | "ratatui", 489 | "serde", 490 | "serde_json", 491 | "strip-ansi-escapes", 492 | "tempfile", 493 | "throbber-widgets-tui", 494 | ] 495 | 496 | [[package]] 497 | name = "nom" 498 | version = "7.1.3" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 501 | dependencies = [ 502 | "memchr", 503 | "minimal-lexical", 504 | ] 505 | 506 | [[package]] 507 | name = "num-conv" 508 | version = "0.1.0" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 511 | 512 | [[package]] 513 | name = "num_threads" 514 | version = "0.1.7" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 517 | dependencies = [ 518 | "libc", 519 | ] 520 | 521 | [[package]] 522 | name = "once_cell" 523 | version = "1.21.3" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 526 | 527 | [[package]] 528 | name = "once_cell_polyfill" 529 | version = "1.70.1" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 532 | 533 | [[package]] 534 | name = "parking_lot" 535 | version = "0.12.4" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 538 | dependencies = [ 539 | "lock_api", 540 | "parking_lot_core", 541 | ] 542 | 543 | [[package]] 544 | name = "parking_lot_core" 545 | version = "0.9.11" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 548 | dependencies = [ 549 | "cfg-if", 550 | "libc", 551 | "redox_syscall", 552 | "smallvec", 553 | "windows-targets 0.52.6", 554 | ] 555 | 556 | [[package]] 557 | name = "paste" 558 | version = "1.0.15" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 561 | 562 | [[package]] 563 | name = "portable-atomic" 564 | version = "1.11.1" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 567 | 568 | [[package]] 569 | name = "portable-atomic-util" 570 | version = "0.2.4" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 573 | dependencies = [ 574 | "portable-atomic", 575 | ] 576 | 577 | [[package]] 578 | name = "powerfmt" 579 | version = "0.2.0" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 582 | 583 | [[package]] 584 | name = "ppv-lite86" 585 | version = "0.2.21" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 588 | dependencies = [ 589 | "zerocopy", 590 | ] 591 | 592 | [[package]] 593 | name = "proc-macro2" 594 | version = "1.0.95" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 597 | dependencies = [ 598 | "unicode-ident", 599 | ] 600 | 601 | [[package]] 602 | name = "quote" 603 | version = "1.0.40" 604 | source = "registry+https://github.com/rust-lang/crates.io-index" 605 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 606 | dependencies = [ 607 | "proc-macro2", 608 | ] 609 | 610 | [[package]] 611 | name = "r-efi" 612 | version = "5.3.0" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 615 | 616 | [[package]] 617 | name = "rand" 618 | version = "0.8.5" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 621 | dependencies = [ 622 | "libc", 623 | "rand_chacha", 624 | "rand_core", 625 | ] 626 | 627 | [[package]] 628 | name = "rand_chacha" 629 | version = "0.3.1" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 632 | dependencies = [ 633 | "ppv-lite86", 634 | "rand_core", 635 | ] 636 | 637 | [[package]] 638 | name = "rand_core" 639 | version = "0.6.4" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 642 | dependencies = [ 643 | "getrandom 0.2.16", 644 | ] 645 | 646 | [[package]] 647 | name = "ratatui" 648 | version = "0.29.0" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 651 | dependencies = [ 652 | "bitflags", 653 | "cassowary", 654 | "compact_str", 655 | "crossterm", 656 | "indoc", 657 | "instability", 658 | "itertools", 659 | "lru", 660 | "paste", 661 | "strum", 662 | "time", 663 | "unicode-segmentation", 664 | "unicode-truncate", 665 | "unicode-width 0.2.0", 666 | ] 667 | 668 | [[package]] 669 | name = "redox_syscall" 670 | version = "0.5.17" 671 | source = "registry+https://github.com/rust-lang/crates.io-index" 672 | checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" 673 | dependencies = [ 674 | "bitflags", 675 | ] 676 | 677 | [[package]] 678 | name = "regex" 679 | version = "1.11.1" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 682 | dependencies = [ 683 | "aho-corasick", 684 | "memchr", 685 | "regex-automata", 686 | "regex-syntax", 687 | ] 688 | 689 | [[package]] 690 | name = "regex-automata" 691 | version = "0.4.9" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 694 | dependencies = [ 695 | "aho-corasick", 696 | "memchr", 697 | "regex-syntax", 698 | ] 699 | 700 | [[package]] 701 | name = "regex-syntax" 702 | version = "0.8.5" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 705 | 706 | [[package]] 707 | name = "rustix" 708 | version = "0.38.44" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 711 | dependencies = [ 712 | "bitflags", 713 | "errno", 714 | "libc", 715 | "linux-raw-sys 0.4.15", 716 | "windows-sys 0.59.0", 717 | ] 718 | 719 | [[package]] 720 | name = "rustix" 721 | version = "1.0.8" 722 | source = "registry+https://github.com/rust-lang/crates.io-index" 723 | checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" 724 | dependencies = [ 725 | "bitflags", 726 | "errno", 727 | "libc", 728 | "linux-raw-sys 0.9.4", 729 | "windows-sys 0.60.2", 730 | ] 731 | 732 | [[package]] 733 | name = "rustversion" 734 | version = "1.0.21" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 737 | 738 | [[package]] 739 | name = "ryu" 740 | version = "1.0.20" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 743 | 744 | [[package]] 745 | name = "scopeguard" 746 | version = "1.2.0" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 749 | 750 | [[package]] 751 | name = "serde" 752 | version = "1.0.219" 753 | source = "registry+https://github.com/rust-lang/crates.io-index" 754 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 755 | dependencies = [ 756 | "serde_derive", 757 | ] 758 | 759 | [[package]] 760 | name = "serde_derive" 761 | version = "1.0.219" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 764 | dependencies = [ 765 | "proc-macro2", 766 | "quote", 767 | "syn", 768 | ] 769 | 770 | [[package]] 771 | name = "serde_json" 772 | version = "1.0.141" 773 | source = "registry+https://github.com/rust-lang/crates.io-index" 774 | checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" 775 | dependencies = [ 776 | "itoa", 777 | "memchr", 778 | "ryu", 779 | "serde", 780 | ] 781 | 782 | [[package]] 783 | name = "signal-hook" 784 | version = "0.3.18" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 787 | dependencies = [ 788 | "libc", 789 | "signal-hook-registry", 790 | ] 791 | 792 | [[package]] 793 | name = "signal-hook-mio" 794 | version = "0.2.4" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 797 | dependencies = [ 798 | "libc", 799 | "mio", 800 | "signal-hook", 801 | ] 802 | 803 | [[package]] 804 | name = "signal-hook-registry" 805 | version = "1.4.5" 806 | source = "registry+https://github.com/rust-lang/crates.io-index" 807 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 808 | dependencies = [ 809 | "libc", 810 | ] 811 | 812 | [[package]] 813 | name = "simdutf8" 814 | version = "0.1.5" 815 | source = "registry+https://github.com/rust-lang/crates.io-index" 816 | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 817 | 818 | [[package]] 819 | name = "smallvec" 820 | version = "1.15.1" 821 | source = "registry+https://github.com/rust-lang/crates.io-index" 822 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 823 | 824 | [[package]] 825 | name = "static_assertions" 826 | version = "1.1.0" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 829 | 830 | [[package]] 831 | name = "strip-ansi-escapes" 832 | version = "0.2.1" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" 835 | dependencies = [ 836 | "vte", 837 | ] 838 | 839 | [[package]] 840 | name = "strsim" 841 | version = "0.11.1" 842 | source = "registry+https://github.com/rust-lang/crates.io-index" 843 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 844 | 845 | [[package]] 846 | name = "strum" 847 | version = "0.26.3" 848 | source = "registry+https://github.com/rust-lang/crates.io-index" 849 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 850 | dependencies = [ 851 | "strum_macros", 852 | ] 853 | 854 | [[package]] 855 | name = "strum_macros" 856 | version = "0.26.4" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 859 | dependencies = [ 860 | "heck", 861 | "proc-macro2", 862 | "quote", 863 | "rustversion", 864 | "syn", 865 | ] 866 | 867 | [[package]] 868 | name = "syn" 869 | version = "2.0.104" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 872 | dependencies = [ 873 | "proc-macro2", 874 | "quote", 875 | "unicode-ident", 876 | ] 877 | 878 | [[package]] 879 | name = "tempfile" 880 | version = "3.20.0" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 883 | dependencies = [ 884 | "fastrand", 885 | "getrandom 0.3.3", 886 | "once_cell", 887 | "rustix 1.0.8", 888 | "windows-sys 0.59.0", 889 | ] 890 | 891 | [[package]] 892 | name = "thiserror" 893 | version = "1.0.69" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 896 | dependencies = [ 897 | "thiserror-impl", 898 | ] 899 | 900 | [[package]] 901 | name = "thiserror-impl" 902 | version = "1.0.69" 903 | source = "registry+https://github.com/rust-lang/crates.io-index" 904 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 905 | dependencies = [ 906 | "proc-macro2", 907 | "quote", 908 | "syn", 909 | ] 910 | 911 | [[package]] 912 | name = "thread_local" 913 | version = "1.1.9" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 916 | dependencies = [ 917 | "cfg-if", 918 | ] 919 | 920 | [[package]] 921 | name = "throbber-widgets-tui" 922 | version = "0.8.0" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" 925 | dependencies = [ 926 | "rand", 927 | "ratatui", 928 | ] 929 | 930 | [[package]] 931 | name = "time" 932 | version = "0.3.41" 933 | source = "registry+https://github.com/rust-lang/crates.io-index" 934 | checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 935 | dependencies = [ 936 | "deranged", 937 | "libc", 938 | "num-conv", 939 | "num_threads", 940 | "powerfmt", 941 | "serde", 942 | "time-core", 943 | ] 944 | 945 | [[package]] 946 | name = "time-core" 947 | version = "0.1.4" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 950 | 951 | [[package]] 952 | name = "unicode-ident" 953 | version = "1.0.18" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 956 | 957 | [[package]] 958 | name = "unicode-segmentation" 959 | version = "1.12.0" 960 | source = "registry+https://github.com/rust-lang/crates.io-index" 961 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 962 | 963 | [[package]] 964 | name = "unicode-truncate" 965 | version = "1.1.0" 966 | source = "registry+https://github.com/rust-lang/crates.io-index" 967 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 968 | dependencies = [ 969 | "itertools", 970 | "unicode-segmentation", 971 | "unicode-width 0.1.14", 972 | ] 973 | 974 | [[package]] 975 | name = "unicode-width" 976 | version = "0.1.14" 977 | source = "registry+https://github.com/rust-lang/crates.io-index" 978 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 979 | 980 | [[package]] 981 | name = "unicode-width" 982 | version = "0.2.0" 983 | source = "registry+https://github.com/rust-lang/crates.io-index" 984 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 985 | 986 | [[package]] 987 | name = "utf8parse" 988 | version = "0.2.2" 989 | source = "registry+https://github.com/rust-lang/crates.io-index" 990 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 991 | 992 | [[package]] 993 | name = "vte" 994 | version = "0.14.1" 995 | source = "registry+https://github.com/rust-lang/crates.io-index" 996 | checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" 997 | dependencies = [ 998 | "memchr", 999 | ] 1000 | 1001 | [[package]] 1002 | name = "wasi" 1003 | version = "0.11.1+wasi-snapshot-preview1" 1004 | source = "registry+https://github.com/rust-lang/crates.io-index" 1005 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1006 | 1007 | [[package]] 1008 | name = "wasi" 1009 | version = "0.14.2+wasi-0.2.4" 1010 | source = "registry+https://github.com/rust-lang/crates.io-index" 1011 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1012 | dependencies = [ 1013 | "wit-bindgen-rt", 1014 | ] 1015 | 1016 | [[package]] 1017 | name = "winapi" 1018 | version = "0.3.9" 1019 | source = "registry+https://github.com/rust-lang/crates.io-index" 1020 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1021 | dependencies = [ 1022 | "winapi-i686-pc-windows-gnu", 1023 | "winapi-x86_64-pc-windows-gnu", 1024 | ] 1025 | 1026 | [[package]] 1027 | name = "winapi-i686-pc-windows-gnu" 1028 | version = "0.4.0" 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" 1030 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1031 | 1032 | [[package]] 1033 | name = "winapi-x86_64-pc-windows-gnu" 1034 | version = "0.4.0" 1035 | source = "registry+https://github.com/rust-lang/crates.io-index" 1036 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1037 | 1038 | [[package]] 1039 | name = "windows-link" 1040 | version = "0.1.3" 1041 | source = "registry+https://github.com/rust-lang/crates.io-index" 1042 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 1043 | 1044 | [[package]] 1045 | name = "windows-sys" 1046 | version = "0.59.0" 1047 | source = "registry+https://github.com/rust-lang/crates.io-index" 1048 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1049 | dependencies = [ 1050 | "windows-targets 0.52.6", 1051 | ] 1052 | 1053 | [[package]] 1054 | name = "windows-sys" 1055 | version = "0.60.2" 1056 | source = "registry+https://github.com/rust-lang/crates.io-index" 1057 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1058 | dependencies = [ 1059 | "windows-targets 0.53.3", 1060 | ] 1061 | 1062 | [[package]] 1063 | name = "windows-targets" 1064 | version = "0.52.6" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1067 | dependencies = [ 1068 | "windows_aarch64_gnullvm 0.52.6", 1069 | "windows_aarch64_msvc 0.52.6", 1070 | "windows_i686_gnu 0.52.6", 1071 | "windows_i686_gnullvm 0.52.6", 1072 | "windows_i686_msvc 0.52.6", 1073 | "windows_x86_64_gnu 0.52.6", 1074 | "windows_x86_64_gnullvm 0.52.6", 1075 | "windows_x86_64_msvc 0.52.6", 1076 | ] 1077 | 1078 | [[package]] 1079 | name = "windows-targets" 1080 | version = "0.53.3" 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" 1082 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 1083 | dependencies = [ 1084 | "windows-link", 1085 | "windows_aarch64_gnullvm 0.53.0", 1086 | "windows_aarch64_msvc 0.53.0", 1087 | "windows_i686_gnu 0.53.0", 1088 | "windows_i686_gnullvm 0.53.0", 1089 | "windows_i686_msvc 0.53.0", 1090 | "windows_x86_64_gnu 0.53.0", 1091 | "windows_x86_64_gnullvm 0.53.0", 1092 | "windows_x86_64_msvc 0.53.0", 1093 | ] 1094 | 1095 | [[package]] 1096 | name = "windows_aarch64_gnullvm" 1097 | version = "0.52.6" 1098 | source = "registry+https://github.com/rust-lang/crates.io-index" 1099 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1100 | 1101 | [[package]] 1102 | name = "windows_aarch64_gnullvm" 1103 | version = "0.53.0" 1104 | source = "registry+https://github.com/rust-lang/crates.io-index" 1105 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1106 | 1107 | [[package]] 1108 | name = "windows_aarch64_msvc" 1109 | version = "0.52.6" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1112 | 1113 | [[package]] 1114 | name = "windows_aarch64_msvc" 1115 | version = "0.53.0" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1118 | 1119 | [[package]] 1120 | name = "windows_i686_gnu" 1121 | version = "0.52.6" 1122 | source = "registry+https://github.com/rust-lang/crates.io-index" 1123 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1124 | 1125 | [[package]] 1126 | name = "windows_i686_gnu" 1127 | version = "0.53.0" 1128 | source = "registry+https://github.com/rust-lang/crates.io-index" 1129 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1130 | 1131 | [[package]] 1132 | name = "windows_i686_gnullvm" 1133 | version = "0.52.6" 1134 | source = "registry+https://github.com/rust-lang/crates.io-index" 1135 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1136 | 1137 | [[package]] 1138 | name = "windows_i686_gnullvm" 1139 | version = "0.53.0" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1142 | 1143 | [[package]] 1144 | name = "windows_i686_msvc" 1145 | version = "0.52.6" 1146 | source = "registry+https://github.com/rust-lang/crates.io-index" 1147 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1148 | 1149 | [[package]] 1150 | name = "windows_i686_msvc" 1151 | version = "0.53.0" 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" 1153 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1154 | 1155 | [[package]] 1156 | name = "windows_x86_64_gnu" 1157 | version = "0.52.6" 1158 | source = "registry+https://github.com/rust-lang/crates.io-index" 1159 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1160 | 1161 | [[package]] 1162 | name = "windows_x86_64_gnu" 1163 | version = "0.53.0" 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" 1165 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1166 | 1167 | [[package]] 1168 | name = "windows_x86_64_gnullvm" 1169 | version = "0.52.6" 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" 1171 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1172 | 1173 | [[package]] 1174 | name = "windows_x86_64_gnullvm" 1175 | version = "0.53.0" 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" 1177 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1178 | 1179 | [[package]] 1180 | name = "windows_x86_64_msvc" 1181 | version = "0.52.6" 1182 | source = "registry+https://github.com/rust-lang/crates.io-index" 1183 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1184 | 1185 | [[package]] 1186 | name = "windows_x86_64_msvc" 1187 | version = "0.53.0" 1188 | source = "registry+https://github.com/rust-lang/crates.io-index" 1189 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1190 | 1191 | [[package]] 1192 | name = "wit-bindgen-rt" 1193 | version = "0.39.0" 1194 | source = "registry+https://github.com/rust-lang/crates.io-index" 1195 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1196 | dependencies = [ 1197 | "bitflags", 1198 | ] 1199 | 1200 | [[package]] 1201 | name = "zerocopy" 1202 | version = "0.8.26" 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" 1204 | checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" 1205 | dependencies = [ 1206 | "zerocopy-derive", 1207 | ] 1208 | 1209 | [[package]] 1210 | name = "zerocopy-derive" 1211 | version = "0.8.26" 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" 1213 | checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" 1214 | dependencies = [ 1215 | "proc-macro2", 1216 | "quote", 1217 | "syn", 1218 | ] 1219 | -------------------------------------------------------------------------------- /src/drives.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Command, sync::atomic::AtomicU64}; 2 | 3 | use ratatui::layout::Constraint; 4 | use serde_json::Value; 5 | 6 | use crate::widget::TableWidget; 7 | 8 | static NEXT_PART_ID: AtomicU64 = AtomicU64::new(1); 9 | 10 | pub fn get_entry_id() -> u64 { 11 | NEXT_PART_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) 12 | } 13 | 14 | /// Convert 'bytes used' into a Disko-compatible size string 15 | /// 16 | /// Disko (NixOS disk partitioning tool) expects sizes in specific formats: 17 | /// - Exact byte counts like "1024B", "500M", "50G" 18 | /// - "100%" for remaining space 19 | /// 20 | /// If we're near the end of available space, return "100%" to avoid 21 | /// Disko calculation errors due to rounding or alignment issues 22 | pub fn bytes_disko_cfg( 23 | bytes: u64, 24 | total_used_sectors: u64, 25 | sector_size: u64, 26 | total_size: u64, 27 | ) -> String { 28 | let requested_sectors = bytes.div_ceil(sector_size); 29 | // Check if this partition would use most/all remaining space 30 | // Reserve 2048 sectors (1MB at 512 bytes/sector) for disk alignment 31 | let is_rest_of_space = 32 | (requested_sectors + total_used_sectors) >= (total_size.saturating_sub(2048)); 33 | if is_rest_of_space { 34 | log::debug!( 35 | "bytes_disko_cfg: using 100% for bytes {bytes}, total_used_sectors {total_used_sectors}, sector_size {sector_size}, total_size {total_size}" 36 | ); 37 | return "100%".into(); 38 | } 39 | // Use decimal units (powers of 1000) as expected by Disko 40 | const K: f64 = 1000.0; 41 | const M: f64 = 1000.0 * K; 42 | const G: f64 = 1000.0 * M; 43 | const T: f64 = 1000.0 * G; 44 | 45 | let bytes_f = bytes as f64; 46 | if bytes_f >= T { 47 | format!("{:.0}T", bytes_f / T) 48 | } else if bytes_f >= G { 49 | format!("{:.0}G", bytes_f / G) 50 | } else if bytes_f >= M { 51 | format!("{:.0}M", bytes_f / M) 52 | } else if bytes_f >= K { 53 | format!("{:.0}K", bytes_f / K) 54 | } else { 55 | format!("{bytes}B") 56 | } 57 | } 58 | 59 | /// Simple byte size formatter 60 | pub fn bytes_readable(bytes: u64) -> String { 61 | const KIB: u64 = 1 << 10; 62 | const MIB: u64 = 1 << 20; 63 | const GIB: u64 = 1 << 30; 64 | const TIB: u64 = 1 << 40; 65 | 66 | if bytes >= 1 << 40 { 67 | format!("{:.2} TiB", bytes as f64 / TIB as f64) 68 | } else if bytes >= 1 << 30 { 69 | format!("{:.2} GiB", bytes as f64 / GIB as f64) 70 | } else if bytes >= 1 << 20 { 71 | format!("{:.2} MiB", bytes as f64 / MIB as f64) 72 | } else if bytes >= 1 << 10 { 73 | format!("{:.2} KiB", bytes as f64 / KIB as f64) 74 | } else { 75 | bytes.to_string() 76 | } 77 | } 78 | 79 | /// Parse human-readable size strings into sector counts 80 | /// Supports various formats: "50 MiB", "500MB", "25%", "1024B" 81 | /// Returns the equivalent number of sectors for the given sector size 82 | pub fn parse_sectors(s: &str, sector_size: u64, total_sectors: u64) -> Option { 83 | let s = s.trim().to_lowercase(); 84 | 85 | // Define multipliers for both binary (1024-based) and decimal (1000-based) 86 | // units 87 | let units: [(&str, f64); 10] = [ 88 | ("tib", (1u64 << 40) as f64), // 2^40 bytes (binary terabyte) 89 | ("tb", 1_000_000_000_000.0), // 10^12 bytes (decimal terabyte) 90 | ("gib", (1u64 << 30) as f64), // 2^30 bytes (binary gigabyte) 91 | ("gb", 1_000_000_000.0), // 10^9 bytes (decimal gigabyte) 92 | ("mib", (1u64 << 20) as f64), // 2^20 bytes (binary megabyte) 93 | ("mb", 1_000_000.0), // 10^6 bytes (decimal megabyte) 94 | ("kib", (1u64 << 10) as f64), // 2^10 bytes (binary kilobyte) 95 | ("kb", 1_000.0), // 10^3 bytes (decimal kilobyte) 96 | ("b", 1.0), // bytes 97 | ("%", 0.0), // percentage (handled separately) 98 | ]; 99 | 100 | for (unit, multiplier) in units.iter() { 101 | if s.ends_with(unit) { 102 | let num_str = s.trim_end_matches(unit).trim(); 103 | 104 | if *unit == "%" { 105 | // Convert percentage to sectors (e.g., "50%" = half of total_sectors) 106 | return num_str 107 | .parse::() 108 | .ok() 109 | .map(|v| ((v / 100.0) * total_sectors as f64).round() as u64); 110 | } else { 111 | // Convert bytes to sectors by dividing by sector size 112 | return num_str 113 | .parse::() 114 | .ok() 115 | .map(|v| ((v * multiplier) / sector_size as f64).round() as u64); 116 | } 117 | } 118 | } 119 | 120 | // If no unit suffix found, interpret as raw sector count 121 | s.parse::().ok() 122 | } 123 | 124 | /// Convert number of megabytes into sectors 125 | pub fn mb_to_sectors(mb: u64, sector_size: u64) -> u64 { 126 | let bytes = mb * 1024 * 1024; 127 | bytes.div_ceil(sector_size) // round up to nearest sector 128 | } 129 | 130 | /// Discover available disk drives using the `lsblk` command 131 | /// 132 | /// This function safely identifies disk drives that can be used for 133 | /// installation: 134 | /// - Uses `lsblk` to get comprehensive disk information in JSON format 135 | /// - Filters out the drive hosting the current live system (mounted at "/" or 136 | /// "/iso") 137 | /// - Returns structured disk information suitable for partitioning 138 | /// 139 | /// The installer assumes `lsblk` is available (provided by the Nix environment) 140 | pub fn lsblk() -> anyhow::Result> { 141 | /// Check if a device is safe to use for installation 142 | /// 143 | /// A device is considered unsafe if it or any of its partitions 144 | /// are currently being used by the live system 145 | fn is_safe_device(dev: &serde_json::Value) -> bool { 146 | // Check if this device is mounted at critical mount points 147 | if let Some(mount) = dev.get("mountpoint").and_then(|m| m.as_str()) { 148 | if mount == "/" || mount == "/iso" { 149 | // "/" is the root filesystem, "/iso" is common in live environments 150 | return false; 151 | } 152 | } 153 | 154 | // Recursively check all child partitions 155 | if let Some(children) = dev.get("children").and_then(|c| c.as_array()) { 156 | for child in children { 157 | if !is_safe_device(child) { 158 | return false; 159 | } 160 | } 161 | } 162 | 163 | true 164 | } 165 | // Execute lsblk with specific options: 166 | // --json: JSON output format 167 | // -o: specify columns (name, size, type, mount, filesystem, label, start, 168 | // physical sector size) -b: output sizes in bytes (not human-readable) 169 | let output = Command::new("lsblk") 170 | .args([ 171 | "--json", 172 | "-o", 173 | "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,START,PHY-SEC", 174 | "-b", 175 | ]) 176 | .output()?; 177 | 178 | if !output.status.success() { 179 | return Err(anyhow::anyhow!( 180 | "lsblk command failed with status: {}", 181 | output.status 182 | )); 183 | } 184 | 185 | let lsblk_json: Value = serde_json::from_slice(&output.stdout) 186 | .map_err(|e| anyhow::anyhow!("Failed to parse lsblk output as JSON: {}", e))?; 187 | 188 | // Extract and filter block devices from lsblk output 189 | let blockdevices = lsblk_json 190 | .get("blockdevices") 191 | .and_then(|v| v.as_array()) 192 | .ok_or_else(|| anyhow::anyhow!("lsblk output missing 'blockdevices' array"))? 193 | .iter() 194 | .filter(|dev| is_safe_device(dev)) // Only include devices safe for partitioning 195 | .collect::>(); 196 | // Parse each block device, but only include actual disks (not partitions, LVM, 197 | // etc.) 198 | let mut disks = vec![]; 199 | for device in blockdevices { 200 | let dev_type = device 201 | .get("type") 202 | .and_then(|v| v.as_str()) 203 | .ok_or_else(|| anyhow::anyhow!("Device entry missing TYPE"))?; 204 | 205 | // Only process devices of type "disk" (physical drives) 206 | if dev_type == "disk" { 207 | let disk = parse_disk(device.clone())?; 208 | disks.push(disk); 209 | } 210 | } 211 | Ok(disks) 212 | } 213 | 214 | /// Parse a single disk entry from lsblk JSON output into our Disk structure 215 | /// 216 | /// Extracts disk metadata (name, size, sector size) and recursively parses 217 | /// any existing partitions as child objects 218 | pub fn parse_disk(disk: Value) -> anyhow::Result { 219 | let obj = disk 220 | .as_object() 221 | .ok_or_else(|| anyhow::anyhow!("Disk entry is not an object"))?; 222 | 223 | let name = obj 224 | .get("name") 225 | .and_then(|v| v.as_str()) 226 | .ok_or_else(|| anyhow::anyhow!("Disk entry missing NAME"))? 227 | .to_string(); 228 | 229 | let size = obj 230 | .get("size") 231 | .and_then(|v| v.as_u64()) 232 | .ok_or_else(|| anyhow::anyhow!("Disk entry missing or invalid SIZE: {:?}", obj.clone()))?; 233 | 234 | // Get physical sector size, defaulting to 512 bytes (standard for most drives) 235 | let sector_size = obj.get("phy-sec").and_then(|v| v.as_u64()).unwrap_or(512); 236 | 237 | // Parse existing partitions on this disk 238 | let mut layout = Vec::new(); 239 | if let Some(children) = obj.get("children").and_then(|v| v.as_array()) { 240 | for part in children { 241 | let partition = parse_partition(part)?; 242 | layout.push(partition); 243 | } 244 | } 245 | 246 | // Convert byte size to sector count and create disk object 247 | let mut disk = Disk::new(name, size / sector_size, sector_size, layout); 248 | disk.calculate_free_space(); // Calculate available free space between partitions 249 | Ok(disk) 250 | } 251 | 252 | /// Parse a single partition entry from lsblk JSON output 253 | /// 254 | /// Converts lsblk partition data into our DiskItem::Partition structure 255 | pub fn parse_partition(part: &Value) -> anyhow::Result { 256 | let obj = part 257 | .as_object() 258 | .ok_or_else(|| anyhow::anyhow!("Partition entry is not an object"))?; 259 | 260 | let start = obj 261 | .get("start") 262 | .and_then(|v| v.as_u64()) 263 | .ok_or_else(|| anyhow::anyhow!("Partition entry missing or invalid START"))?; 264 | 265 | let size = obj 266 | .get("size") 267 | .and_then(|v| v.as_u64()) 268 | .ok_or_else(|| anyhow::anyhow!("Partition entry missing or invalid SIZE"))?; 269 | 270 | // Get sector size (should match parent disk, but we'll be safe) 271 | let sector_size = obj.get("phy-sec").and_then(|v| v.as_u64()).unwrap_or(512); 272 | 273 | let name = obj 274 | .get("name") 275 | .and_then(|v| v.as_str()) 276 | .map(|s| s.to_string()); 277 | let fs_type = obj 278 | .get("fstype") 279 | .and_then(|v| v.as_str()) 280 | .map(|s| s.to_string()); 281 | let mount_point = obj 282 | .get("mountpoint") 283 | .and_then(|v| v.as_str()) 284 | .map(|s| s.to_string()); 285 | let label = obj 286 | .get("label") 287 | .and_then(|v| v.as_str()) 288 | .map(|s| s.to_string()); 289 | 290 | // Note: lsblk doesn't provide read-only status in our query 291 | let ro = false; 292 | 293 | // Partition flags (like "boot", "esp") would need additional detection 294 | let flags = vec![]; 295 | 296 | // Existing partitions discovered by lsblk are marked as "Exists" 297 | let status = PartStatus::Exists; 298 | 299 | Ok(DiskItem::Partition(Partition::new( 300 | start, 301 | size / sector_size, 302 | sector_size, 303 | status, 304 | name, 305 | fs_type, 306 | mount_point, 307 | label, 308 | ro, 309 | flags, 310 | ))) 311 | } 312 | 313 | /// Return a table showing available disk devices 314 | pub fn disk_table(disks: &[Disk]) -> TableWidget { 315 | let (headers, widths): (Vec, Vec) = DiskTableHeader::disk_table_header_info() 316 | .into_iter() 317 | .unzip(); 318 | let rows: Vec> = disks 319 | .iter() 320 | .map(|d| d.as_table_row(&DiskTableHeader::disk_table_headers())) 321 | .collect(); 322 | TableWidget::new("Disks", widths, headers, rows) 323 | } 324 | 325 | /// Return a table showing available partitions for a disk device 326 | pub fn part_table(disk_items: &[DiskItem], sector_size: u64) -> TableWidget { 327 | let (headers, widths): (Vec, Vec) = 328 | DiskTableHeader::partition_table_header_info() 329 | .into_iter() 330 | .unzip(); 331 | let rows: Vec> = disk_items 332 | .iter() 333 | .map(|item| item.as_table_row(sector_size, &DiskTableHeader::partition_table_headers())) 334 | .collect(); 335 | TableWidget::new("Partitions", widths, headers, rows) 336 | } 337 | 338 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 339 | /// Represents a physical disk drive and its partition layout 340 | /// 341 | /// Tracks both the current partition layout and the original layout 342 | /// discovered at startup, allowing users to revert changes 343 | pub struct Disk { 344 | name: String, 345 | size: u64, // sectors 346 | sector_size: u64, 347 | 348 | initial_layout: Vec, 349 | total_used_sectors: u64, 350 | /// Current partition layout including partitions and free space 351 | /// 352 | /// Partitions use half-open ranges: [start, start+size) 353 | /// This means start sector is included, end sector is excluded 354 | layout: Vec, 355 | } 356 | 357 | impl Disk { 358 | pub fn new(name: String, size: u64, sector_size: u64, layout: Vec) -> Self { 359 | let mut new = Self { 360 | name, 361 | size, 362 | sector_size, 363 | initial_layout: layout.clone(), 364 | total_used_sectors: 0, 365 | layout, 366 | }; 367 | new.calculate_free_space(); 368 | new 369 | } 370 | /// Get info as a table row, based on the given field names (`headers`) 371 | pub fn as_table_row(&self, headers: &[DiskTableHeader]) -> Vec { 372 | headers 373 | .iter() 374 | .map(|h| { 375 | match h { 376 | DiskTableHeader::Status => "".into(), 377 | DiskTableHeader::Device => self.name.clone(), 378 | DiskTableHeader::Label => "".into(), 379 | DiskTableHeader::Start => "".into(), // Disk does not have a start sector in this context 380 | DiskTableHeader::End => "".into(), // Disk does not have an end sector in this context 381 | DiskTableHeader::Size => bytes_readable(self.size_bytes()), 382 | DiskTableHeader::FSType => "".into(), 383 | DiskTableHeader::MountPoint => "".into(), 384 | DiskTableHeader::Flags => "".into(), 385 | DiskTableHeader::ReadOnly => "no".into(), 386 | } 387 | }) 388 | .collect() 389 | } 390 | /// Convert the disk into a `disko` config 391 | pub fn as_disko_cfg(&mut self) -> serde_json::Value { 392 | let mut partitions = serde_json::Map::new(); 393 | for item in &self.layout { 394 | if let DiskItem::Partition(p) = item { 395 | if *p.status() == PartStatus::Delete { 396 | continue; 397 | } 398 | let name = p 399 | .label() 400 | .map(|s| s.to_string()) 401 | .unwrap_or_else(|| format!("part{}", p.id())); 402 | let size = bytes_disko_cfg( 403 | p.size_bytes(p.sector_size), 404 | self.total_used_sectors, 405 | p.sector_size, 406 | self.size, 407 | ); 408 | 409 | if p.flags.contains(&"esp".to_string()) { 410 | partitions.insert( 411 | name, 412 | serde_json::json!({ 413 | "size": size, 414 | "type": p.fs_gpt_code(p.flags.contains(&"esp".to_string())), 415 | "format": p.disko_fs_type(), 416 | "mountpoint": p.mount_point(), 417 | }), 418 | ); 419 | } else { 420 | partitions.insert( 421 | name, 422 | serde_json::json!({ 423 | "size": size, 424 | "format": p.disko_fs_type(), 425 | "mountpoint": p.mount_point(), 426 | }), 427 | ); 428 | } 429 | self.total_used_sectors += p.size(); 430 | } 431 | } 432 | self.total_used_sectors = 0; 433 | 434 | serde_json::json!({ 435 | "device": format!("/dev/{}", self.name), 436 | "type": "disk", 437 | "content": { 438 | "type": "gpt", 439 | "partitions": partitions 440 | } 441 | }) 442 | } 443 | pub fn name(&self) -> &str { 444 | &self.name 445 | } 446 | pub fn set_name>(&mut self, name: S) { 447 | self.name = name.into(); 448 | } 449 | pub fn size(&self) -> u64 { 450 | self.size 451 | } 452 | pub fn set_size(&mut self, size: u64) { 453 | self.size = size; 454 | } 455 | pub fn sector_size(&self) -> u64 { 456 | self.sector_size 457 | } 458 | pub fn set_sector_size(&mut self, sector_size: u64) { 459 | self.sector_size = sector_size; 460 | } 461 | pub fn layout(&self) -> &[DiskItem] { 462 | &self.layout 463 | } 464 | pub fn partitions(&self) -> impl Iterator { 465 | self.layout.iter().filter_map(|item| { 466 | if let DiskItem::Partition(p) = item { 467 | Some(p) 468 | } else { 469 | None 470 | } 471 | }) 472 | } 473 | pub fn partitions_mut(&mut self) -> impl Iterator { 474 | self.layout.iter_mut().filter_map(|item| { 475 | if let DiskItem::Partition(p) = item { 476 | Some(p) 477 | } else { 478 | None 479 | } 480 | }) 481 | } 482 | pub fn partition_by_id(&self, id: u64) -> Option<&Partition> { 483 | self.partitions().find(|p| p.id() == id) 484 | } 485 | pub fn partition_by_id_mut(&mut self, id: u64) -> Option<&mut Partition> { 486 | self.partitions_mut().find(|p| p.id() == id) 487 | } 488 | pub fn free_spaces(&self) -> impl Iterator { 489 | self.layout.iter().filter_map(|item| { 490 | if let DiskItem::FreeSpace { start, size, .. } = *item { 491 | Some((start, size)) 492 | } else { 493 | None 494 | } 495 | }) 496 | } 497 | pub fn reset_layout(&mut self) { 498 | self.layout = self.initial_layout.clone(); 499 | self.calculate_free_space(); 500 | } 501 | pub fn size_bytes(&self) -> u64 { 502 | self.size * self.sector_size 503 | } 504 | pub fn remove_partition(&mut self, id: u64) -> anyhow::Result<()> { 505 | let Some(part_idx) = self.layout.iter().position(|item| item.id() == id) else { 506 | return Err(anyhow::anyhow!("No item with id {}", id)); 507 | }; 508 | let DiskItem::Partition(_) = &mut self.layout[part_idx] else { 509 | return Err(anyhow::anyhow!("Item with id {} is not a partition", id)); 510 | }; 511 | self.layout.remove(part_idx); 512 | 513 | self.calculate_free_space(); 514 | Ok(()) 515 | } 516 | pub fn new_partition(&mut self, part: Partition) -> anyhow::Result<()> { 517 | // Ensure the new partition does not overlap existing partitions 518 | self.clear_free_space(); 519 | log::debug!("Adding new partition: {part:#?}"); 520 | log::debug!("Current layout: {:#?}", self.layout); 521 | let new_start = part.start(); 522 | let new_end = part.end(); 523 | for item in &self.layout { 524 | if let DiskItem::Partition(p) = item { 525 | if p.status == PartStatus::Delete { 526 | // We do not care about deleted partitions 527 | continue; 528 | } 529 | let existing_start = p.start(); 530 | let existing_end = p.end(); 531 | if (new_start < existing_end) && (new_end > existing_start) { 532 | return Err(anyhow::anyhow!( 533 | "New partition overlaps with existing partition" 534 | )); 535 | } 536 | } 537 | } 538 | self.layout.push(DiskItem::Partition(part)); 539 | log::debug!("Updated layout: {:#?}", self.layout); 540 | self.calculate_free_space(); 541 | log::debug!("After calculating free space: {:#?}", self.layout); 542 | Ok(()) 543 | } 544 | 545 | pub fn clear_free_space(&mut self) { 546 | self 547 | .layout 548 | .retain(|item| !matches!(item, DiskItem::FreeSpace { .. })); 549 | self.normalize_layout(); 550 | } 551 | 552 | /// Recalculate free space gaps between partitions 553 | /// 554 | /// This function rebuilds the layout by: 555 | /// 1. Keeping deleted partitions at the beginning (for UI visibility) 556 | /// 2. Finding gaps between existing partitions 557 | /// 3. Adding FreeSpace entries for gaps larger than 5MB 558 | pub fn calculate_free_space(&mut self) { 559 | // Separate deleted partitions from active ones 560 | // Deleted partitions are kept for UI display but don't affect free space 561 | // calculation 562 | let (deleted, mut rest) = self.layout.iter().cloned().partition::, _>( 563 | |item| matches!(item, DiskItem::Partition(p) if p.status == PartStatus::Delete), 564 | ); 565 | 566 | // Sort remaining partitions by their start position on disk 567 | rest.sort_by_key(|p| p.start()); 568 | 569 | let mut gaps = vec![]; 570 | // Start at sector 2048 (1MB) to leave space for disk alignment and boot sectors 571 | let mut cursor = 2048u64; 572 | 573 | // Walk through partitions in order, identifying gaps 574 | for p in rest.iter() { 575 | let DiskItem::Partition(p) = p else { 576 | continue; // Skip non-partition items 577 | }; 578 | 579 | // Check if there's a gap before this partition 580 | if p.start() > cursor { 581 | let size = p.start() - cursor; 582 | 583 | // Only create FreeSpace entries for gaps larger than 5MB 584 | // Smaller gaps are typically unusable due to alignment requirements 585 | if size > mb_to_sectors(5, self.sector_size) { 586 | gaps.push(DiskItem::FreeSpace { 587 | id: get_entry_id(), 588 | start: cursor, 589 | size, 590 | }); 591 | } 592 | } 593 | 594 | // Move cursor past this partition 595 | cursor = p.start() + p.size(); 596 | } 597 | 598 | // Check for free space at the end of the disk 599 | if cursor < self.size { 600 | let size = self.size - cursor; 601 | if size > mb_to_sectors(5, self.sector_size) { 602 | gaps.push(DiskItem::FreeSpace { 603 | id: get_entry_id(), 604 | start: cursor, 605 | size: self.size - cursor, 606 | }); 607 | } 608 | } 609 | 610 | let mut rest_with_gaps = rest.into_iter().chain(gaps).collect::>(); 611 | rest_with_gaps.sort_by_key(|item| item.start()); 612 | let new_layout = deleted.into_iter().chain(rest_with_gaps).collect(); 613 | self.layout = new_layout; 614 | self.normalize_layout(); 615 | } 616 | 617 | /// Clean up the disk layout by sorting and merging adjacent free space 618 | /// 619 | /// This ensures: 620 | /// - Deleted partitions appear first (for UI visibility) 621 | /// - Adjacent free space regions are merged into single entries 622 | pub fn normalize_layout(&mut self) { 623 | // Separate deleted partitions and put them at the beginning for UI organization 624 | let (mut new_layout, others): (Vec<_>, Vec<_>) = self 625 | .layout() 626 | .to_vec() 627 | .into_iter() 628 | .partition(|item| matches!(item, DiskItem::Partition(p) if p.status == PartStatus::Delete)); 629 | let mut last_free: Option<(u64, u64)> = None; // Track adjacent free space: (start, size) 630 | 631 | new_layout.extend(others); 632 | let mut new_new_layout = vec![]; 633 | 634 | // Merge adjacent free space while preserving partition order 635 | for item in &new_layout { 636 | match item { 637 | DiskItem::FreeSpace { start, size, .. } => { 638 | if let Some((last_start, last_size)) = last_free { 639 | // Extend the current free space region 640 | last_free = Some((last_start, last_size + size)); 641 | } else { 642 | // Start tracking a new free space region 643 | last_free = Some((*start, *size)); 644 | } 645 | } 646 | DiskItem::Partition(p) => { 647 | // If we have accumulated free space, add it to the layout 648 | if let Some((start, size)) = last_free.take() { 649 | new_new_layout.push(DiskItem::FreeSpace { 650 | id: get_entry_id(), 651 | start, 652 | size, 653 | }); 654 | } 655 | // Add the partition 656 | new_new_layout.push(DiskItem::Partition(p.clone())); 657 | } 658 | } 659 | } 660 | // Add any remaining free space at the end 661 | if let Some((start, size)) = last_free.take() { 662 | new_new_layout.push(DiskItem::FreeSpace { 663 | id: get_entry_id(), 664 | start, 665 | size, 666 | }); 667 | } 668 | 669 | self.layout = new_new_layout; 670 | } 671 | 672 | /// Apply the default NixOS partitioning scheme to this disk 673 | /// 674 | /// Creates a standard two-partition layout: 675 | /// - 500MB FAT32 boot partition (ESP) at the beginning 676 | /// - Remaining space for root filesystem (specified fs_type or default) 677 | /// 678 | /// All existing partitions are marked for deletion 679 | pub fn use_default_layout(&mut self, fs_type: Option) { 680 | // Remove all free space and newly created partitions 681 | // Keep existing partitions so user can see what will be deleted 682 | self.layout.retain(|item| match item { 683 | DiskItem::FreeSpace { .. } => false, // Remove all free space 684 | DiskItem::Partition(part) => part.status != PartStatus::Create, // Remove created partitions 685 | }); 686 | // Mark all existing partitions for deletion 687 | for part in self.layout.iter_mut() { 688 | let DiskItem::Partition(part) = part else { 689 | continue; 690 | }; 691 | part.status = PartStatus::Delete 692 | } 693 | // Create 500MB FAT32 boot partition starting at sector 2048 (1MB aligned) 694 | // This serves as the EFI System Partition (ESP) 695 | let boot_part = Partition::new( 696 | 2048, // Start at 1MB boundary 697 | mb_to_sectors(500, self.sector_size), // 500MB size 698 | self.sector_size, 699 | PartStatus::Create, 700 | None, 701 | Some("fat32".into()), // FAT32 filesystem 702 | Some("/boot".into()), // Mount at /boot 703 | Some("BOOT".into()), // Partition label 704 | false, 705 | vec!["boot".into(), "esp".into()], // Mark as bootable ESP 706 | ); 707 | // Create root partition using all remaining space 708 | let root_part = Partition::new( 709 | boot_part.end(), // Start immediately after boot partition 710 | self.size - (boot_part.end()), // Use all remaining disk space 711 | self.sector_size, 712 | PartStatus::Create, 713 | None, 714 | fs_type, // User-specified or default filesystem 715 | Some("/".into()), // Mount as root filesystem 716 | Some("ROOT".into()), // Partition label 717 | false, 718 | vec![], // No special flags 719 | ); 720 | // Add the new partitions to the layout 721 | self.layout.push(DiskItem::Partition(boot_part)); 722 | self.layout.push(DiskItem::Partition(root_part)); 723 | } 724 | } 725 | 726 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 727 | pub enum DiskItem { 728 | Partition(Partition), 729 | FreeSpace { id: u64, start: u64, size: u64 }, // size in sectors 730 | } 731 | 732 | impl DiskItem { 733 | pub fn start(&self) -> u64 { 734 | match self { 735 | DiskItem::Partition(p) => p.start, 736 | DiskItem::FreeSpace { start, .. } => *start, 737 | } 738 | } 739 | pub fn id(&self) -> u64 { 740 | match self { 741 | DiskItem::Partition(p) => p.id(), 742 | DiskItem::FreeSpace { id, .. } => *id, 743 | } 744 | } 745 | pub fn mount_point(&self) -> Option<&str> { 746 | match self { 747 | DiskItem::Partition(p) => p.mount_point(), 748 | DiskItem::FreeSpace { .. } => None, 749 | } 750 | } 751 | pub fn as_table_row(&self, sector_size: u64, headers: &[DiskTableHeader]) -> Vec { 752 | match self { 753 | DiskItem::Partition(p) => { 754 | headers 755 | .iter() 756 | .map(|h| { 757 | match h { 758 | DiskTableHeader::Status => match p.status() { 759 | PartStatus::Delete => "delete".into(), 760 | PartStatus::Modify => "modify".into(), 761 | PartStatus::Exists => "existing".into(), 762 | PartStatus::Create => "create".into(), 763 | PartStatus::Unknown => "unknown".into(), 764 | }, 765 | DiskTableHeader::Device => p.name().unwrap_or("").into(), 766 | DiskTableHeader::Label => p.label().unwrap_or("").into(), 767 | DiskTableHeader::Start => p.start().to_string(), 768 | DiskTableHeader::End => (p.end() - 1).to_string(), 769 | DiskTableHeader::Size => bytes_readable(p.size_bytes(sector_size)), 770 | DiskTableHeader::FSType => p.fs_type().unwrap_or("").into(), 771 | DiskTableHeader::MountPoint => p.mount_point().unwrap_or("").into(), 772 | DiskTableHeader::Flags => p.flags().join(","), 773 | DiskTableHeader::ReadOnly => "".into(), // Not applicable for partitions 774 | } 775 | }) 776 | .collect() 777 | } 778 | DiskItem::FreeSpace { start, size, .. } => { 779 | headers 780 | .iter() 781 | .map(|h| { 782 | match h { 783 | DiskTableHeader::Status => "free".into(), 784 | DiskTableHeader::Device => "".into(), 785 | DiskTableHeader::Label => "".into(), 786 | DiskTableHeader::Start => start.to_string(), 787 | DiskTableHeader::End => ((start + size) - 1).to_string(), 788 | DiskTableHeader::Size => bytes_readable(size * sector_size), 789 | DiskTableHeader::FSType => "".into(), 790 | DiskTableHeader::MountPoint => "".into(), 791 | DiskTableHeader::Flags => "".into(), 792 | DiskTableHeader::ReadOnly => "".into(), // Not applicable for free space 793 | } 794 | }) 795 | .collect() 796 | } 797 | } 798 | } 799 | } 800 | 801 | #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 802 | pub enum PartStatus { 803 | Delete, 804 | Modify, 805 | Create, 806 | Exists, 807 | Unknown, 808 | } 809 | 810 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 811 | pub struct Partition { 812 | id: u64, 813 | start: u64, // sectors 814 | size: u64, // also sectors 815 | sector_size: u64, // bytes 816 | status: PartStatus, 817 | name: Option, 818 | fs_type: Option, 819 | mount_point: Option, 820 | ro: bool, 821 | label: Option, 822 | flags: Vec, 823 | } 824 | 825 | #[allow(clippy::too_many_arguments)] 826 | impl Partition { 827 | pub fn new( 828 | start: u64, 829 | size: u64, 830 | sector_size: u64, 831 | status: PartStatus, 832 | name: Option, 833 | fs_type: Option, 834 | mount_point: Option, 835 | label: Option, 836 | ro: bool, 837 | flags: Vec, 838 | ) -> Self { 839 | Self { 840 | id: get_entry_id(), 841 | start, 842 | sector_size, 843 | size, 844 | status, 845 | name, 846 | fs_type, 847 | mount_point, 848 | label, 849 | ro, 850 | flags, 851 | } 852 | } 853 | pub fn id(&self) -> u64 { 854 | self.id 855 | } 856 | pub fn name(&self) -> Option<&str> { 857 | self.name.as_deref() 858 | } 859 | pub fn set_name>(&mut self, name: S) { 860 | self.name = Some(name.into()); 861 | } 862 | pub fn start(&self) -> u64 { 863 | self.start 864 | } 865 | pub fn end(&self) -> u64 { 866 | self.start + self.size 867 | } 868 | pub fn set_start(&mut self, start: u64) { 869 | self.start = start; 870 | } 871 | pub fn size(&self) -> u64 { 872 | self.size 873 | } 874 | pub fn set_size(&mut self, size: u64) { 875 | self.size = size; 876 | } 877 | pub fn status(&self) -> &PartStatus { 878 | &self.status 879 | } 880 | pub fn set_status(&mut self, status: PartStatus) { 881 | self.status = status; 882 | } 883 | pub fn fs_type(&self) -> Option<&str> { 884 | self.fs_type.as_deref() 885 | } 886 | /// Disko expects `vfat` for any fat fs types 887 | pub fn disko_fs_type(&self) -> Option<&'static str> { 888 | match self.fs_type.as_deref()? { 889 | "ext4" => Some("ext4"), 890 | "ext3" => Some("ext3"), 891 | "ext2" => Some("ext2"), 892 | "btrfs" => Some("btrfs"), 893 | "xfs" => Some("xfs"), 894 | "fat12" => Some("vfat"), 895 | "fat16" => Some("vfat"), 896 | "fat32" => Some("vfat"), 897 | "ntfs" => Some("ntfs"), 898 | "swap" => Some("swap"), 899 | _ => None, 900 | } 901 | } 902 | pub fn fs_gpt_code(&self, is_esp: bool) -> Option<&'static str> { 903 | match self.fs_type.as_deref()? { 904 | "ext4" | "ext3" | "ext2" | "btrfs" | "xfs" => Some("8300"), 905 | "fat12" | "fat16" | "fat32" => { 906 | if is_esp { 907 | Some("EF00") 908 | } else { 909 | Some("0700") 910 | } 911 | } 912 | "ntfs" => Some("0700"), 913 | "swap" => Some("8200"), 914 | _ => None, 915 | } 916 | } 917 | pub fn set_fs_type>(&mut self, fs_type: S) { 918 | self.fs_type = Some(fs_type.into()); 919 | } 920 | pub fn mount_point(&self) -> Option<&str> { 921 | self.mount_point.as_deref() 922 | } 923 | pub fn set_mount_point>(&mut self, mount_point: S) { 924 | self.mount_point = Some(mount_point.into()); 925 | } 926 | pub fn label(&self) -> Option<&str> { 927 | self.label.as_deref() 928 | } 929 | pub fn set_label>(&mut self, label: S) { 930 | self.label = Some(label.into()); 931 | } 932 | pub fn flags(&self) -> &[String] { 933 | &self.flags 934 | } 935 | pub fn add_flag>(&mut self, flag: S) { 936 | let flag_str = flag.into(); 937 | if !self.flags.contains(&flag_str) { 938 | self.flags.push(flag_str); 939 | } 940 | } 941 | pub fn add_flags(&mut self, flags: impl Iterator>) { 942 | for flag in flags { 943 | let flag = flag.into(); 944 | if !self.flags.contains(&flag) { 945 | self.flags.push(flag); 946 | } 947 | } 948 | } 949 | pub fn remove_flag>(&mut self, flag: S) { 950 | self.flags.retain(|f| f != flag.as_ref()); 951 | } 952 | pub fn remove_flags>(&mut self, flags: impl Iterator) { 953 | let flag_set: Vec = flags.map(|f| f.as_ref().to_string()).collect(); 954 | self.flags.retain(|f| !flag_set.contains(f)); 955 | } 956 | pub fn size_bytes(&self, sector_size: u64) -> u64 { 957 | self.size * sector_size 958 | } 959 | } 960 | 961 | pub struct PartitionBuilder { 962 | start: Option, 963 | size: Option, 964 | sector_size: Option, 965 | status: PartStatus, 966 | name: Option, 967 | fs_type: Option, 968 | mount_point: Option, 969 | label: Option, 970 | ro: Option, 971 | flags: Vec, 972 | } 973 | 974 | impl PartitionBuilder { 975 | pub fn new() -> Self { 976 | Self { 977 | start: None, 978 | size: None, 979 | sector_size: None, 980 | status: PartStatus::Unknown, 981 | name: None, 982 | fs_type: None, 983 | mount_point: None, 984 | label: None, 985 | ro: None, 986 | flags: vec![], 987 | } 988 | } 989 | pub fn start(mut self, start: u64) -> Self { 990 | self.start = Some(start); 991 | self 992 | } 993 | pub fn size(mut self, size: u64) -> Self { 994 | self.size = Some(size); 995 | self 996 | } 997 | pub fn sector_size(mut self, sector_size: u64) -> Self { 998 | self.sector_size = Some(sector_size); 999 | self 1000 | } 1001 | pub fn status(mut self, status: PartStatus) -> Self { 1002 | self.status = status; 1003 | self 1004 | } 1005 | pub fn fs_type>(mut self, fs_type: S) -> Self { 1006 | self.fs_type = Some(fs_type.into()); 1007 | self 1008 | } 1009 | pub fn mount_point>(mut self, mount_point: S) -> Self { 1010 | self.mount_point = Some(mount_point.into()); 1011 | self 1012 | } 1013 | pub fn read_only(mut self, ro: bool) -> Self { 1014 | self.ro = Some(ro); 1015 | self 1016 | } 1017 | pub fn label>(mut self, label: S) -> Self { 1018 | self.label = Some(label.into()); 1019 | self 1020 | } 1021 | pub fn add_flag>(mut self, flag: S) -> Self { 1022 | let flag_str = flag.into(); 1023 | if !self.flags.contains(&flag_str) { 1024 | self.flags.push(flag_str); 1025 | } 1026 | self 1027 | } 1028 | pub fn build(self) -> anyhow::Result { 1029 | let start = self 1030 | .start 1031 | .ok_or_else(|| anyhow::anyhow!("start is required"))?; 1032 | let size = self 1033 | .size 1034 | .ok_or_else(|| anyhow::anyhow!("size is required"))?; 1035 | let sector_size = self.sector_size.unwrap_or(512); // default to 512 if not specified 1036 | let mount_point = self 1037 | .mount_point 1038 | .ok_or_else(|| anyhow::anyhow!("mount_point is required"))?; 1039 | let ro = self.ro.unwrap_or(false); 1040 | if size == 0 { 1041 | return Err(anyhow::anyhow!("size must be greater than zero")); 1042 | } 1043 | let id = get_entry_id(); 1044 | Ok(Partition { 1045 | id, 1046 | start, 1047 | size, 1048 | sector_size, 1049 | status: self.status, 1050 | name: self.name, 1051 | fs_type: self.fs_type, 1052 | mount_point: Some(mount_point), 1053 | label: self.label, 1054 | ro, 1055 | flags: self.flags, 1056 | }) 1057 | } 1058 | } 1059 | 1060 | impl Default for PartitionBuilder { 1061 | fn default() -> Self { 1062 | Self::new() 1063 | } 1064 | } 1065 | 1066 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 1067 | pub enum DiskTableHeader { 1068 | Status, 1069 | Device, 1070 | Start, 1071 | End, 1072 | Label, 1073 | Size, 1074 | FSType, 1075 | MountPoint, 1076 | Flags, 1077 | ReadOnly, 1078 | } 1079 | 1080 | impl DiskTableHeader { 1081 | pub fn header_info(&self) -> (String, Constraint) { 1082 | match self { 1083 | DiskTableHeader::Status => ("Status".into(), Constraint::Min(10)), 1084 | DiskTableHeader::Device => ("Device".into(), Constraint::Min(11)), 1085 | DiskTableHeader::Label => ("Label".into(), Constraint::Min(15)), 1086 | DiskTableHeader::Start => ("Start".into(), Constraint::Min(22)), 1087 | DiskTableHeader::End => ("End".into(), Constraint::Min(22)), 1088 | DiskTableHeader::Size => ("Size".into(), Constraint::Min(11)), 1089 | DiskTableHeader::FSType => ("FS Type".into(), Constraint::Min(7)), 1090 | DiskTableHeader::MountPoint => ("Mount Point".into(), Constraint::Min(15)), 1091 | DiskTableHeader::Flags => ("Flags".into(), Constraint::Min(20)), 1092 | DiskTableHeader::ReadOnly => ("Read Only".into(), Constraint::Min(21)), 1093 | } 1094 | } 1095 | pub fn all_headers() -> Vec { 1096 | vec![ 1097 | DiskTableHeader::Status, 1098 | DiskTableHeader::Device, 1099 | DiskTableHeader::Label, 1100 | DiskTableHeader::Start, 1101 | DiskTableHeader::End, 1102 | DiskTableHeader::Size, 1103 | DiskTableHeader::FSType, 1104 | DiskTableHeader::MountPoint, 1105 | DiskTableHeader::Flags, 1106 | DiskTableHeader::ReadOnly, 1107 | ] 1108 | } 1109 | pub fn partition_table_headers() -> Vec { 1110 | vec![ 1111 | DiskTableHeader::Status, 1112 | DiskTableHeader::Device, 1113 | DiskTableHeader::Label, 1114 | DiskTableHeader::Start, 1115 | DiskTableHeader::End, 1116 | DiskTableHeader::Size, 1117 | DiskTableHeader::FSType, 1118 | DiskTableHeader::MountPoint, 1119 | DiskTableHeader::Flags, 1120 | ] 1121 | } 1122 | pub fn disk_table_headers() -> Vec { 1123 | vec![ 1124 | DiskTableHeader::Device, 1125 | DiskTableHeader::Size, 1126 | DiskTableHeader::ReadOnly, 1127 | ] 1128 | } 1129 | pub fn disk_table_header_info() -> Vec<(String, Constraint)> { 1130 | Self::disk_table_headers() 1131 | .iter() 1132 | .map(|h| h.header_info()) 1133 | .collect() 1134 | } 1135 | pub fn partition_table_header_info() -> Vec<(String, Constraint)> { 1136 | Self::partition_table_headers() 1137 | .iter() 1138 | .map(|h| h.header_info()) 1139 | .collect() 1140 | } 1141 | pub fn all_header_info() -> Vec<(String, Constraint)> { 1142 | Self::all_headers() 1143 | .iter() 1144 | .map(|h| h.header_info()) 1145 | .collect() 1146 | } 1147 | } 1148 | --------------------------------------------------------------------------------