├── .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 | 
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 |
--------------------------------------------------------------------------------