This commit is contained in:
zj
2026-02-11 04:37:13 +08:00
parent 6645d8dea3
commit a859956597
5 changed files with 2476 additions and 409 deletions

176
AGENTS.md
View File

@@ -4,6 +4,8 @@
**BootRepairTool** 是一个用于修复 Linux 系统 GRUB 引导的图形化工具。适用于 Live USB/CD 环境,帮助用户修复损坏的 GRUB 引导加载器。
**v2.0 更新**: 参考 Calamares 安装程序的引导安装模块进行了全面优化新增多架构支持、EFI fallback、混合启动模式、btrfs 子卷支持等功能。
## 项目结构
```
@@ -25,58 +27,127 @@ BootRepairTool/
### 1. 分区扫描 (`backend.py:scan_partitions`)
- 使用 `lsblk -J` 获取磁盘和分区信息
- 识别 EFI 系统分区 (ESP)
- **NEW**: 支持 LVM 逻辑卷检测
- **NEW**: 支持 LUKS 加密分区检测
- **NEW**: 支持 btrfs 子卷检测
- 过滤 Live 系统自身的分区
### 2. 系统挂载 (`backend.py:mount_target_system`)
- 挂载根分区 (/)
- **NEW**: 支持 btrfs 子卷挂载
- 挂载独立 /boot 分区(可选)
- 挂载 EFI 分区UEFI 模式)
- 绑定伪文件系统 (/dev, /proc, /sys, /run)
### 3. 发行版检测 (`backend.py:detect_distro_type`)
- 支持: Arch, CentOS/RHEL, Debian, Ubuntu
- 通过读取 `/etc/os-release` 识别
支持以下发行版:
- **Arch 系**: Arch Linux, Manjaro, EndeavourOS, Garuda, CachyOS
- **Debian 系**: Debian, Ubuntu
- **RHEL 系**: CentOS, RHEL, Rocky Linux, AlmaLinux, Fedora
- **openSUSE**: Leap, Tumbleweed
- **NEW**: Void Linux
- **NEW**: Gentoo
- **NEW**: NixOS
### 4. GRUB 修复 (`backend.py:chroot_and_repair_grub`)
- **BIOS 模式**: `grub-install /dev/sdX`
- **UEFI 模式**: `grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB`
- 更新 GRUB 配置:
- Debian/Ubuntu: `update-grub`
- Arch: `grub-mkconfig -o /boot/grub/grub.cfg`
- CentOS: `grub2-mkconfig -o /boot/grub2/grub.cfg`
### 5. 卸载清理 (`backend.py:unmount_target_system`)
#### BIOS 模式
- `grub-install --target=i386-pc --recheck --force /dev/sdX`
#### UEFI 模式 (参考 Calamares 实现)
- 自动检测系统架构 (x86_64/i386/arm64/loongarch64)
- 获取正确的 EFI 参数 (target, grub_file, boot_file)
- **多重安装策略**(自动回退):
1. 标准安装(带 NVRAM
2. Live 环境/可移动模式(`--removable`
3. 无 NVRAM 模式(`--no-nvram`
- **NEW**: EFI Fallback 安装(复制到 `/EFI/Boot/bootx64.efi`
- **NEW**: 支持自定义 EFI 启动项名称
#### 混合启动模式 (Hybrid GRUB)
- **NEW**: 同时安装 BIOS 和 UEFI 引导
- 适用于需要在不同固件模式下启动的场景
#### GRUB 配置更新
根据发行版自动选择正确的命令:
- Debian/Ubuntu: `update-grub``grub-mkconfig`
- Arch: `grub-mkconfig -o /boot/grub/grub.cfg`
- CentOS/RHEL/Fedora/openSUSE: `grub2-mkconfig -o /boot/grub2/grub.cfg`
### 5. 系统检测功能
#### EFI 检测 (`backend.get_efi_word_size`)
- 读取 `/sys/firmware/efi/fw_platform_size`
- 支持 32 位和 64 位 EFI
#### 架构检测 (`backend.get_grub_efi_parameters`)
支持多种架构:
| 架构 | EFI Target | GRUB 文件 | Boot 文件 |
|------|-----------|-----------|-----------|
| x86_64 | x86_64-efi | grubx64.efi | bootx64.efi |
| i386 | i386-efi | grubia32.efi | bootia32.efi |
| arm64 | arm64-efi | grubaa64.efi | bootaa64.efi |
| loongarch64 | loongarch64-efi | grubloongarch64.efi | bootloongarch64.efi |
#### Secure Boot 检测 (`backend.check_secure_boot_status`)
- 通过 `mokutil --sb-state` 检测
- 通过 EFI 变量检测
- 显示 Secure Boot 状态
#### Live 环境检测 (`backend.is_live_environment`)
- 检测 `/sys/firmware/efi/efivars` 可访问性
- 自动调整安装策略
### 6. 卸载清理 (`backend.py:unmount_target_system`)
- 逆序卸载绑定的文件系统
- 卸载 EFI/boot/根分区
- 清理临时挂载点
- **NEW**: 重试机制处理繁忙资源
## 前端界面 (`frontend.py`)
### 主要组件
1. **分区选择区域**
1. **系统信息区域**
- 显示 CPU 架构
- 显示 EFI 位数32/64
- 显示 Live 环境状态
- 显示 Secure Boot 状态
2. **分区选择区域**
- 根分区 (/) - 必选
- **NEW**: btrfs 子卷选择(自动显示)
- 独立 /boot 分区 - 可选
- EFI 系统分区 (ESP) - UEFI 模式下推荐
2. **目标磁盘选择**
- 物理磁盘选择 (如 /dev/sda)
- UEFI 模式复选框(自动检测 Live 环境)
3. **目标磁盘选择**
- 物理磁盘选择
- UEFI 模式复选框(根据 Live 环境自动设置
3. **日志窗口**
- 彩色日志输出(信息/警告/错误/成功
4. **高级选项**
- **NEW**: 混合启动模式(同时安装 BIOS + UEFI
- **NEW**: EFI Fallback 选项
- **NEW**: 自定义 EFI 启动项名称
- **NEW**: 强制安装选项
5. **日志窗口**
- 彩色日志输出(调试/信息/警告/错误/成功/步骤)
- 时间戳显示
- 自动滚动
- 使用等宽字体Consolas
### 工作流程
```
扫描分区 → 用户选择分区 → 点击修复 → 挂载系统
扫描分区 → 用户选择分区 → 检测系统信息
配置高级选项 → 点击修复 → 挂载系统 →
检测发行版 → chroot修复GRUB → 卸载分区 → 完成
```
### 线程安全
修复过程在独立线程中运行,避免 UI 卡死。使用 `master.after()` 进行线程安全的日志更新。
### 自动功能
- **自动选择目标磁盘**: 根据根分区路径推断父磁盘
- **自动检测 btrfs**: 显示子卷选择器
- **自动检测 LVM**: 提示手动选择磁盘
- **UEFI 模式建议**: 根据 Live 环境给出建议
## 代码规范
@@ -88,10 +159,26 @@ success, stdout, stderr = run_command([...])
success, disks, partitions, efi_parts, err = scan_partitions()
```
### 日志系统
使用分级日志系统:
```python
log_debug(msg) # 调试信息(灰色)
log_info(msg) # 一般信息(黑色)
log_warning(msg) # 警告(橙色)
log_error(msg) # 错误(红色)
log_success(msg) # 成功(绿色)
log_step(msg) # 步骤(蓝色加粗)
```
支持日志回调,可将日志实时显示到前端:
```python
backend.set_log_callback(callback_function)
```
### 错误处理
- 命令执行失败时自动清理已挂载的分区
- 日志记录使用 `[Backend]` 前缀
- GUI 使用弹窗显示关键错误
- 详细的错误信息返回
- 多重回退策略(如 GRUB 安装)
## 使用限制
@@ -100,27 +187,42 @@ success, disks, partitions, efi_parts, err = scan_partitions()
3. **单线程修复**: 同时只能进行一个修复任务
4. **UEFI 自动检测**: 根据 Live 环境自动设置 UEFI 模式
## 故障排除
### EFI 启动问题
1. **标准模式安装失败**: 工具会自动尝试 removable 模式
2. **无法找到启动项**: 启用 EFI Fallback 选项
3. **Secure Boot 问题**: 需要在 BIOS 中禁用 Secure Boot
4. **固件不兼容**: 某些固件需要特定的 EFI 文件路径
### 分区问题
1. **LVM 逻辑卷**: 需要手动选择目标磁盘
2. **LUKS 加密**: 需要预先解锁加密分区
3. **btrfs 子卷**: 工具会自动检测并显示选择器
## 测试
直接运行 `backend.py` 可进行基础测试(仅扫描分区):
直接运行 `backend.py` 可进行基础测试
```bash
sudo python backend.py
```
完整修复测试需要取消注释 `__main__` 块中的测试代码并设置实际分区。
完整修复测试需要设置实际分区参数
## 参考
本工具 v2.0 参考了以下项目:
- **Calamares**: https://calamares.io
- 引导安装模块 (`bootloader/main.py`)
- EFI 参数获取逻辑
- 多重安装策略
## 潜在改进点
1. **添加 requirements.txt**: 当前无第三方依赖
2. **国际化**: 当前仅支持中文界面
3. **配置文件**: 支持保存/加载常用配置
4. **日志持久化**: 将日志保存到文件
5. **多语言发行版支持**: 扩展 detect_distro_type
6. **Btrfs/LVM 支持**: 当前仅支持标准分区
## 注意事项
- **数据风险**: 操作磁盘有数据丢失风险,建议备份
- **EFI 分区警告**: UEFI 模式下未选择 ESP 会弹出警告
- **临时挂载点**: 使用 `/mnt_grub_repair_<timestamp>` 格式
- **依赖命令**: 需要系统中存在 `lsblk`, `mount`, `umount`, `grub-install`, `grub-mkconfig`/`update-grub`
1. **systemd-boot 支持**: 添加 systemd-boot 安装选项
2. **rEFInd 支持**: 添加 rEFInd 安装选项(后端已支持)
3. **ZFS 支持**: 完善 ZFS 文件系统处理
4. **LUKS 解锁**: 集成 LUKS 解锁功能
5. **图形化分区**: 添加分区可视化显示
6. **多语言**: 国际化支持
7. **日志持久化**: 将日志保存到文件

Binary file not shown.

1109
backend.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,124 +1,268 @@
# frontend.py
# 参考 Calamares 引导安装模块优化
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import ttk, messagebox, scrolledtext
import threading
import os
import re
import backend # 导入后端逻辑
import backend
class GrubRepairApp:
def __init__(self, master):
self.master = master
master.title("Linux GRUB 引导修复工具")
master.geometry("850x650") # 调整窗口大小
master.resizable(True, True) # 允许窗口大小调整
master.title("Linux GRUB 引导修复工具 v2.0")
master.geometry("900x750")
master.resizable(True, True)
# 设置窗口最小大小
master.minsize(800, 600)
self.mount_point = None
self.selected_root_partition_info = None # 存储字典信息
self.selected_root_partition_info = None
self.selected_boot_partition_info = None
self.selected_efi_partition_info = None
self.selected_target_disk_info = None
self.is_uefi_mode = False
self.btrfs_subvolume = None
self.all_disks_data = {} # {display_name: {"name": "/dev/sda"}}
self.all_partitions_data = {} # {display_name: {"name": "/dev/sda1", ...}}
self.all_efi_partitions_data = {} # {display_name: {"name": "/dev/sda1", ...}}
self.all_disks_data = {}
self.all_partitions_data = {}
self.all_efi_partitions_data = {}
# 系统信息
self.system_info = {}
self._detect_system_info()
self.create_widgets()
self.scan_partitions_gui()
def create_widgets(self):
# Frame for partition selection
self.partition_frame = ttk.LabelFrame(self.master, text="1. 选择目标系统分区", padding="10")
self.partition_frame.pack(padx=10, pady=5, fill="x")
self.partition_frame.columnconfigure(1, weight=1) # 让 Combobox 填充宽度
# 设置后端日志回调
backend.set_log_callback(self._backend_log_callback)
ttk.Label(self.partition_frame, text="根分区 (/)").grid(row=0, column=0, padx=5, pady=2, sticky="w")
def _detect_system_info(self):
"""检测系统信息"""
self.system_info["efi_bits"] = backend.get_efi_word_size()
self.system_info["cpu_arch"] = __import__('platform').machine()
self.system_info["is_live"] = backend.is_live_environment()
sb_supported, sb_enabled = backend.check_secure_boot_status()
self.system_info["sb_supported"] = sb_supported
self.system_info["sb_enabled"] = sb_enabled
# 检测 Live 环境 UEFI 状态
self.system_info["live_is_uefi"] = os.path.exists("/sys/firmware/efi")
def create_widgets(self):
"""创建 GUI 组件"""
# 创建主滚动区域
main_canvas = tk.Canvas(self.master)
scrollbar = ttk.Scrollbar(self.master, orient="vertical", command=main_canvas.yview)
self.main_frame = ttk.Frame(main_canvas)
self.main_frame.bind(
"<Configure>",
lambda e: main_canvas.configure(scrollregion=main_canvas.bbox("all"))
)
main_canvas.create_window((0, 0), window=self.main_frame, anchor="nw")
main_canvas.configure(yscrollcommand=scrollbar.set)
main_canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# 鼠标滚轮支持
def on_mousewheel(event):
main_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
main_canvas.bind_all("<MouseWheel>", on_mousewheel)
# ===== 系统信息区域 =====
self.info_frame = ttk.LabelFrame(self.main_frame, text="系统信息", padding="10")
self.info_frame.pack(padx=10, pady=5, fill="x")
info_text = f"""
架构: {self.system_info.get('cpu_arch', 'Unknown')} |
EFI位数: {self.system_info.get('efi_bits', 'Unknown')}位 |
Live环境: {'' if self.system_info.get('is_live') else ''} |
Secure Boot: {'已启用' if self.system_info.get('sb_enabled') else '已禁用/不支持'}
"""
ttk.Label(self.info_frame, text=info_text.strip()).pack(anchor="w")
# ===== 分区选择区域 =====
self.partition_frame = ttk.LabelFrame(self.main_frame, text="1. 选择目标系统分区", padding="10")
self.partition_frame.pack(padx=10, pady=5, fill="x")
self.partition_frame.columnconfigure(1, weight=1)
# 根分区
ttk.Label(self.partition_frame, text="根分区 (/) *").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.root_partition_combo = ttk.Combobox(self.partition_frame, state="readonly")
self.root_partition_combo.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
self.root_partition_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.root_partition_combo.bind("<<ComboboxSelected>>", self.on_root_partition_selected)
ttk.Label(self.partition_frame, text="独立 /boot 分区 (可选)").grid(row=1, column=0, padx=5, pady=2, sticky="w")
# btrfs 子卷选择(初始隐藏)
self.btrfs_frame = ttk.Frame(self.partition_frame)
self.btrfs_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=5, pady=2)
self.btrfs_frame.grid_remove() # 初始隐藏
ttk.Label(self.btrfs_frame, text="Btrfs 子卷:").pack(side="left", padx=(0, 5))
self.btrfs_subvolume_combo = ttk.Combobox(self.btrfs_frame, state="readonly", width=30)
self.btrfs_subvolume_combo.pack(side="left", fill="x", expand=True)
ttk.Button(self.btrfs_frame, text="刷新", command=self._refresh_btrfs_subvolumes).pack(side="left", padx=(5, 0))
# /boot 分区
ttk.Label(self.partition_frame, text="独立 /boot 分区:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.boot_partition_combo = ttk.Combobox(self.partition_frame, state="readonly")
self.boot_partition_combo.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
self.boot_partition_combo.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
ttk.Label(self.partition_frame, text="EFI 系统分区 (ESP, 仅UEFI)").grid(row=2, column=0, padx=5, pady=2, sticky="w")
# EFI 分区
ttk.Label(self.partition_frame, text="EFI 系统分区 (ESP)").grid(row=3, column=0, padx=5, pady=5, sticky="w")
self.efi_partition_combo = ttk.Combobox(self.partition_frame, state="readonly")
self.efi_partition_combo.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
self.efi_partition_combo.grid(row=3, column=1, padx=5, pady=5, sticky="ew")
# Frame for target disk selection
self.disk_frame = ttk.LabelFrame(self.master, text="2. 选择GRUB安装目标磁盘", padding="10")
# ===== 目标磁盘选择 =====
self.disk_frame = ttk.LabelFrame(self.main_frame, text="2. 选择GRUB安装目标", padding="10")
self.disk_frame.pack(padx=10, pady=5, fill="x")
self.disk_frame.columnconfigure(1, weight=1)
ttk.Label(self.disk_frame, text="目标磁盘 (例如 /dev/sda)").grid(row=0, column=0, padx=5, pady=2, sticky="w")
ttk.Label(self.disk_frame, text="目标磁盘 *").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.target_disk_combo = ttk.Combobox(self.disk_frame, state="readonly")
self.target_disk_combo.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
self.target_disk_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.uefi_mode_var = tk.BooleanVar(value=False)
self.uefi_check = ttk.Checkbutton(self.disk_frame, text="目标系统使用UEFI启动 (Live环境检测为UEFI)", variable=self.uefi_mode_var)
self.uefi_check.grid(row=1, column=0, columnspan=2, padx=5, pady=2, sticky="w")
# UEFI 模式选择
self.uefi_mode_var = tk.BooleanVar(value=self.system_info.get("live_is_uefi", False))
self.is_uefi_mode = self.uefi_mode_var.get()
# 尝试自动检测Live环境是否为UEFI
if os.path.exists("/sys/firmware/efi"):
self.uefi_mode_var.set(True)
self.uefi_check.config(state="disabled") # 如果Live是UEFI通常目标系统也是且不建议更改
self.is_uefi_mode = True # 直接设置
else:
self.uefi_check.config(state="normal")
self.is_uefi_mode = False # 直接设置
self.uefi_check.config(text="目标系统使用UEFI启动 (Live环境检测为BIOS)")
uefi_text = f"使用 UEFI 模式 (Live环境检测为{'UEFI' if self.system_info.get('live_is_uefi') else 'BIOS'})"
self.uefi_check = ttk.Checkbutton(
self.disk_frame,
text=uefi_text,
variable=self.uefi_mode_var,
command=self.on_uefi_mode_changed
)
self.uefi_check.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky="w")
self.uefi_mode_var.trace_add("write", self.on_uefi_check_changed)
# 如果 Live 环境是 UEFI提示用户
if self.system_info.get("live_is_uefi"):
ttk.Label(
self.disk_frame,
text="⚠️ Live 环境以 UEFI 模式启动,建议保持 UEFI 模式选中",
foreground="orange"
).grid(row=2, column=0, columnspan=2, padx=5, sticky="w")
# ===== 高级选项 =====
self.advanced_frame = ttk.LabelFrame(self.main_frame, text="3. 高级选项", padding="10")
self.advanced_frame.pack(padx=10, pady=5, fill="x")
# Start Repair Button
self.start_button = ttk.Button(self.master, text="3. 开始修复 GRUB", command=self.start_repair_thread)
self.start_button.pack(padx=10, pady=10, fill="x")
# 混合启动模式
self.hybrid_mode_var = tk.BooleanVar(value=False)
self.hybrid_check = ttk.Checkbutton(
self.advanced_frame,
text="混合启动模式(同时安装 BIOS 和 UEFI 引导)",
variable=self.hybrid_mode_var
)
self.hybrid_check.grid(row=0, column=0, columnspan=2, padx=5, pady=2, sticky="w")
# Progress and Log
self.log_frame = ttk.LabelFrame(self.master, text="修复日志", padding="10")
# EFI Fallback
self.fallback_var = tk.BooleanVar(value=True)
self.fallback_check = ttk.Checkbutton(
self.advanced_frame,
text="安装 EFI Fallback推荐提高兼容性",
variable=self.fallback_var
)
self.fallback_check.grid(row=1, column=0, columnspan=2, padx=5, pady=2, sticky="w")
# EFI Bootloader ID
ttk.Label(self.advanced_frame, text="EFI 启动项名称:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.bootloader_id_var = tk.StringVar(value="GRUB")
self.bootloader_id_entry = ttk.Entry(self.advanced_frame, textvariable=self.bootloader_id_var, width=20)
self.bootloader_id_entry.grid(row=2, column=1, padx=5, pady=5, sticky="w")
# 额外选项
self.force_install_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
self.advanced_frame,
text="强制安装(--force",
variable=self.force_install_var
).grid(row=3, column=0, columnspan=2, padx=5, pady=2, sticky="w")
# ===== 操作按钮 =====
self.button_frame = ttk.Frame(self.main_frame)
self.button_frame.pack(padx=10, pady=10, fill="x")
self.start_button = ttk.Button(
self.button_frame,
text="🔧 开始修复 GRUB",
command=self.start_repair_thread
)
self.start_button.pack(side="left", padx=(0, 5))
self.rescan_button = ttk.Button(
self.button_frame,
text="🔄 重新扫描分区",
command=self.scan_partitions_gui
)
self.rescan_button.pack(side="left", padx=5)
# ===== 日志区域 =====
self.log_frame = ttk.LabelFrame(self.main_frame, text="修复日志", padding="10")
self.log_frame.pack(padx=10, pady=5, fill="both", expand=True)
self.log_text = tk.Text(self.log_frame, wrap="word", height=15, state="disabled", font=("Monospace", 10))
self.log_text = scrolledtext.ScrolledText(
self.log_frame,
wrap="word",
height=15,
state="disabled",
font=("Consolas", 10)
)
self.log_text.pack(padx=5, pady=5, fill="both", expand=True)
self.log_scrollbar = ttk.Scrollbar(self.log_text, command=self.log_text.yview)
self.log_scrollbar.pack(side="right", fill="y")
self.log_text.config(yscrollcommand=self.log_scrollbar.set)
def log_message(self, message, level="info"):
"""
线程安全的日志记录函数。
"""
# 配置日志标签颜色
self.log_text.tag_config("debug", foreground="gray")
self.log_text.tag_config("info", foreground="black")
self.log_text.tag_config("warning", foreground="orange")
self.log_text.tag_config("error", foreground="red")
self.log_text.tag_config("success", foreground="green")
self.log_text.tag_config("step", foreground="blue", font=("Consolas", 10, "bold"))
def _backend_log_callback(self, message: str, level):
"""后端日志回调函数"""
level_str = level.value if hasattr(level, 'value') else str(level).lower()
self._append_log(message, level_str)
def log_message(self, message: str, level: str = "info"):
"""线程安全的日志记录"""
self.master.after(0, self._append_log, message, level)
def _append_log(self, message, level):
def _append_log(self, message: str, level: str = "info"):
"""添加日志到文本框"""
self.log_text.config(state="normal")
tag = "info"
if level == "error":
tag = "error"
self.log_text.tag_config("error", foreground="red")
elif level == "warning":
tag = "warning"
self.log_text.tag_config("warning", foreground="orange")
elif level == "success":
tag = "success"
self.log_text.tag_config("success", foreground="green")
# 添加时间戳
import datetime
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
log_line = f"[{timestamp}] {message}\n"
self.log_text.insert(tk.END, message + "\n", tag)
self.log_text.insert(tk.END, log_line, level)
self.log_text.see(tk.END)
self.log_text.config(state="disabled")
def scan_partitions_gui(self):
"""扫描分区"""
self.log_message("正在扫描分区...", "info")
self.rescan_button.config(state="disabled")
# 清空现有数据
self.root_partition_combo.set('')
self.boot_partition_combo.set('')
self.efi_partition_combo.set('')
self.target_disk_combo.set('')
success, disks, partitions, efi_partitions, err = backend.scan_partitions()
if not success:
self.log_message(f"分区扫描失败: {err}", "error")
messagebox.showerror("错误", f"无法扫描分区: {err}\n请确保您有sudo权限并已安装lsblk。")
self.rescan_button.config(state="normal")
return
# 清空旧数据
@@ -126,31 +270,46 @@ class GrubRepairApp:
self.all_partitions_data = {}
self.all_efi_partitions_data = {}
# 更新磁盘列表
disk_display_names = []
for d in disks:
display_name = d["name"]
display_name = f"{d['name']} ({d.get('size', 'unknown')})"
self.all_disks_data[display_name] = d
disk_display_names.append(display_name)
self.target_disk_combo['values'] = disk_display_names
# 更新分区列表
partition_display_names = []
for p in partitions:
display_name = f"{p['name']} ({p['fstype']} - {p['size']})"
extra_info = []
if p.get('is_lvm'):
extra_info.append("LVM")
if p.get('is_btrfs'):
extra_info.append("Btrfs")
if p.get('is_luks'):
extra_info.append("LUKS")
extra_str = f" [{', '.join(extra_info)}]" if extra_info else ""
display_name = f"{p['name']} ({p['fstype']} - {p['size']}){extra_str}"
self.all_partitions_data[display_name] = p
partition_display_names.append(display_name)
self.root_partition_combo['values'] = partition_display_names
self.boot_partition_combo['values'] = [""] + partition_display_names # 允许不选择独立/boot
self.root_partition_combo['values'] = partition_display_names
self.boot_partition_combo['values'] = [""] + partition_display_names
# 更新 EFI 分区列表
efi_partition_display_names = []
for p in efi_partitions:
display_name = f"{p['name']} ({p['fstype']} - {p['size']})"
self.all_efi_partitions_data[display_name] = p
efi_partition_display_names.append(display_name)
self.efi_partition_combo['values'] = [""] + efi_partition_display_names # 允许不选择EFI分区
self.efi_partition_combo['values'] = [""] + efi_partition_display_names
self.log_message("分区扫描完成。请选择目标系统分区", "info")
self.log_message(f"扫描完成: 发现 {len(disks)} 个磁盘, {len(partitions)}分区", "success")
self.rescan_button.config(state="normal")
def on_root_partition_selected(self, event):
def on_root_partition_selected(self, event=None):
"""根分区选择事件"""
selected_display = self.root_partition_combo.get()
self.selected_root_partition_info = self.all_partitions_data.get(selected_display)
@@ -158,152 +317,260 @@ class GrubRepairApp:
root_part_name = self.selected_root_partition_info['name']
self.log_message(f"已选择根分区: {root_part_name}", "info")
# 检是否是 LVM 逻辑卷
# 检是否为 btrfs
if self.selected_root_partition_info.get('fstype') == 'btrfs':
self.log_message("检测到 Btrfs 文件系统", "info")
self.btrfs_frame.grid() # 显示子卷选择
self._refresh_btrfs_subvolumes()
else:
self.btrfs_frame.grid_remove() # 隐藏子卷选择
self.btrfs_subvolume = None
# 自动选择目标磁盘
is_lvm = self.selected_root_partition_info.get('is_lvm', False)
if is_lvm:
# LVM 逻辑卷无法直接从路径推断父磁盘,提示用户手动选择
self.target_disk_combo.set("")
self.selected_target_disk_info = None
self.log_message("检测到 LVM 逻辑卷,请手动选择 GRUB 安装的目标磁盘", "warning")
self.log_message("检测到 LVM 逻辑卷,请手动选择目标磁盘", "warning")
else:
# 尝试根据分区所在的磁盘自动选择目标磁盘
# /dev/sda1 -> /dev/sda
# /dev/nvme0n1p1 -> /dev/nvme0n1
# /dev/mmcblk0p1 -> /dev/mmcblk0
disk_name_from_root = re.sub(r'(\d+)?p?\d+$', '', root_part_name)
if disk_name_from_root in self.target_disk_combo['values']:
self.target_disk_combo.set(disk_name_from_root)
self.selected_target_disk_info = self.all_disks_data.get(disk_name_from_root)
# 根据分区路径推断磁盘
disk_name_from_root = self._extract_disk_from_partition(root_part_name)
# 在磁盘列表中查找匹配项
for disk_display in self.target_disk_combo['values']:
if disk_name_from_root in disk_display:
self.target_disk_combo.set(disk_display)
self.selected_target_disk_info = self.all_disks_data.get(disk_display)
self.log_message(f"自动选择目标磁盘: {self.selected_target_disk_info['name']}", "info")
break
else:
self.target_disk_combo.set("")
self.selected_target_disk_info = None
self.log_message("无法自动选择目标磁盘,请手动选择。", "warning")
else:
self.selected_root_partition_info = None
self.target_disk_combo.set("")
self.selected_target_disk_info = None
self.btrfs_frame.grid_remove()
def on_uefi_check_changed(self, *args):
def _extract_disk_from_partition(self, partition: str) -> str:
"""从分区路径提取磁盘路径"""
# /dev/sda1 -> /dev/sda
# /dev/nvme0n1p1 -> /dev/nvme0n1
# /dev/mmcblk0p1 -> /dev/mmcblk0
# /dev/mapper/vg-lv -> 保持原样LVM
if partition.startswith("/dev/mapper/"):
return partition # LVM 无法自动推断
# 移除分区号
import re
patterns = [
r'^(/dev/nvme\d+n\d+)p\d+$', # NVMe
r'^(/dev/mmcblk\d+)p\d+$', # eMMC
r'^(/dev/[a-zA-Z]+)\d+$', # SATA/SCSI
]
for pattern in patterns:
match = re.match(pattern, partition)
if match:
return match.group(1)
return partition
def _refresh_btrfs_subvolumes(self):
"""刷新 btrfs 子卷列表"""
if not self.selected_root_partition_info:
return
partition = self.selected_root_partition_info['name']
subvolumes = backend.get_btrfs_root_subvolume(partition)
# 尝试获取所有子卷
try:
import subprocess
result = subprocess.run(
["sudo", "btrfs", "subvolume", "list", partition],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
all_subvols = ["(默认 - 不指定)"]
for line in result.stdout.strip().split('\n'):
match = re.search(r'path\s+(\S+)', line)
if match:
all_subvols.append(match.group(1))
self.btrfs_subvolume_combo['values'] = all_subvols
if len(all_subvols) > 1:
self.btrfs_subvolume_combo.set(all_subvols[0])
except Exception as e:
self.log_message(f"获取 btrfs 子卷失败: {e}", "warning")
def on_uefi_mode_changed(self):
"""UEFI 模式切换事件"""
self.is_uefi_mode = self.uefi_mode_var.get()
self.log_message(f"UEFI模式选择: {'启用' if self.is_uefi_mode else '禁用'}", "info")
self.log_message(f"UEFI 模式: {'启用' if self.is_uefi_mode else '禁用'}", "info")
# 如果启用 UEFI建议必须选择 EFI 分区
if self.is_uefi_mode:
self.log_message("提示: UEFI 模式建议指定 EFI 系统分区", "info")
def start_repair_thread(self):
# 禁用按钮防止重复点击
"""启动修复线程"""
# 获取用户选择
self.selected_root_partition_info = self.all_partitions_data.get(self.root_partition_combo.get())
self.selected_boot_partition_info = self.all_partitions_data.get(self.boot_partition_combo.get()) or None
self.selected_efi_partition_info = self.all_efi_partitions_data.get(self.efi_partition_combo.get()) or None
self.selected_target_disk_info = self.all_disks_data.get(self.target_disk_combo.get())
self.is_uefi_mode = self.uefi_mode_var.get()
# 获取 btrfs 子卷
btrfs_subvol = self.btrfs_subvolume_combo.get() if hasattr(self, 'btrfs_subvolume_combo') else ""
if btrfs_subvol and btrfs_subvol != "(默认 - 不指定)":
self.btrfs_subvolume = btrfs_subvol
else:
self.btrfs_subvolume = None
# 验证输入
if not self.selected_root_partition_info:
messagebox.showerror("错误", "请选择目标系统的根分区!")
return
if not self.selected_target_disk_info and not self.is_uefi_mode:
messagebox.showerror("错误", "BIOS 模式下必须选择 GRUB 安装的目标磁盘!")
return
# UEFI 模式下检查 EFI 分区
if self.is_uefi_mode and not self.selected_efi_partition_info:
response = messagebox.askyesno(
"警告",
"您选择了 UEFI 模式,但未选择 EFI 系统分区 (ESP)。\n"
"这可能导致修复失败。是否继续?"
)
if not response:
return
# 禁用按钮
self.start_button.config(state="disabled")
self.rescan_button.config(state="disabled")
# 清空日志
self.log_text.config(state="normal")
self.log_text.delete(1.0, tk.END)
self.log_text.config(state="disabled")
self.log_message("--- GRUB 修复过程开始 ---", "info")
# 获取用户选择
self.selected_root_partition_info = self.all_partitions_data.get(self.root_partition_combo.get())
self.selected_boot_partition_info = self.all_partitions_data.get(self.boot_partition_combo.get())
self.selected_efi_partition_info = self.all_efi_partitions_data.get(self.efi_partition_combo.get())
self.selected_target_disk_info = self.all_disks_data.get(self.target_disk_combo.get())
self.is_uefi_mode = self.uefi_mode_var.get()
self.log_message("=" * 60, "step")
self.log_message("GRUB 引导修复过程开始", "step")
self.log_message("=" * 60, "step")
if not self.selected_root_partition_info:
messagebox.showerror("错误", "请选择目标系统的根分区!")
self.log_message("未选择根分区,修复中止。", "error")
self.start_button.config(state="normal")
return
if not self.selected_target_disk_info:
messagebox.showerror("错误", "请选择GRUB要安装到的目标磁盘")
self.log_message("未选择目标磁盘,修复中止。", "error")
self.start_button.config(state="normal")
return
# 再次确认UEFI模式下是否选择了EFI分区
if self.is_uefi_mode and not self.selected_efi_partition_info:
response = messagebox.askyesno("警告", "您选择了UEFI模式但未选择EFI系统分区 (ESP)。这可能导致修复失败。是否继续?")
if not response:
self.log_message("用户取消未选择EFI分区。", "warning")
self.start_button.config(state="normal")
return
# 启动新线程进行修复避免UI卡死
repair_thread = threading.Thread(target=self._run_repair_process)
# 启动修复线程
repair_thread = threading.Thread(target=self._run_repair_process, daemon=True)
repair_thread.start()
def _run_repair_process(self):
"""执行修复过程"""
try:
root_part_path = self.selected_root_partition_info['name']
boot_part_path = self.selected_boot_partition_info['name'] if self.selected_boot_partition_info else None
efi_part_path = self.selected_efi_partition_info['name'] if self.selected_efi_partition_info else None
target_disk_path = self.selected_target_disk_info['name']
target_disk_path = self.selected_target_disk_info['name'] if self.selected_target_disk_info else None
self.log_message(f"选择的根分区: {root_part_path}", "info")
self.log_message(f"选择的独立 /boot 分区: {boot_part_path if boot_part_path else ''}", "info")
self.log_message(f"选择的EFI分区: {efi_part_path if efi_part_path else ''}", "info")
self.log_message(f"GRUB安装目标磁盘: {target_disk_path}", "info")
self.log_message(f"UEFI模式: {'启用' if self.is_uefi_mode else '禁用'}", "info")
self.log_message(f"根分区: {root_part_path}", "info")
self.log_message(f"/boot 分区: {boot_part_path or ''}", "info")
self.log_message(f"EFI 分区: {efi_part_path or ''}", "info")
self.log_message(f"目标磁盘: {target_disk_path or 'N/A (UEFI only)'}", "info")
self.log_message(f"UEFI 模式: {'' if self.is_uefi_mode else ''}", "info")
self.log_message(f"Btrfs 子卷: {self.btrfs_subvolume or ''}", "info")
# 1. 挂载目标系统
self.log_message("正在挂载目标系统分区...", "info")
self.log_message("正在挂载目标系统分区...", "step")
mount_ok, self.mount_point, mount_err = backend.mount_target_system(
root_part_path,
boot_part_path,
efi_part_path if self.is_uefi_mode else None # 只有UEFI模式才挂载EFI分区
efi_part_path if self.is_uefi_mode else None,
self.btrfs_subvolume
)
if not mount_ok:
self.log_message(f"挂载失败,修复中止: {mount_err}", "error")
self.master.after(0, lambda: messagebox.showerror("修复失败", f"无法挂载目标系统分区。请检查分区选择和权限。\n错误: {mount_err}"))
self.log_message(f"挂载失败: {mount_err}", "error")
self.master.after(0, lambda: messagebox.showerror("修复失败", f"无法挂载目标系统:\n{mount_err}"))
return
# 2. 尝试识别发行版类型
self.log_message("正在检测目标系统发行版...", "info")
# 2. 检测发行版
self.log_message("正在检测目标系统发行版...", "step")
distro_type = backend.detect_distro_type(self.mount_point)
self.log_message(f"检测到目标系统发行版: {distro_type}", "info")
self.log_message(f"检测到发行版: {distro_type}", "info")
# 3. Chroot 并修复 GRUB
self.log_message("正在进入Chroot环境并修复GRUB...", "info")
self.log_message("正在进入 Chroot 环境并修复 GRUB...", "step")
install_hybrid = self.hybrid_mode_var.get()
use_fallback = self.fallback_var.get()
bootloader_id = self.bootloader_id_var.get() or "GRUB"
self.log_message(f"混合模式: {'' if install_hybrid else ''}", "info")
self.log_message(f"EFI Fallback: {'' if use_fallback else ''}", "info")
self.log_message(f"启动项名称: {bootloader_id}", "info")
repair_ok, repair_err = backend.chroot_and_repair_grub(
self.mount_point,
target_disk_path,
target_disk_path or "", # UEFI 模式下可能为空
self.is_uefi_mode,
distro_type
distro_type,
install_hybrid=install_hybrid,
use_fallback=use_fallback,
efi_bootloader_id=bootloader_id
)
if not repair_ok:
self.log_message(f"GRUB 修复失败: {repair_err}", "error")
self.master.after(0, lambda: messagebox.showerror("修复失败", f"GRUB修复过程中发生错误。\n错误: {repair_err}"))
# 即使失败,也要尝试卸载
self.master.after(0, lambda: messagebox.showerror("修复失败", f"GRUB 修复失败:\n{repair_err}"))
else:
self.log_message("GRUB 修复命令执行成功!", "success")
# 4. 卸载分区
self.log_message("正在卸载目标系统分区...", "info")
self.log_message("正在卸载目标系统分区...", "step")
unmount_ok, unmount_err = backend.unmount_target_system(self.mount_point)
if not unmount_ok:
self.log_message(f"卸载分区失败: {unmount_err}", "error")
self.master.after(0, lambda: messagebox.showwarning("卸载警告", f"部分分区可能未能正确卸载。请手动检查并卸载。\n错误: {unmount_err}"))
self.log_message(f"卸载分区失败: {unmount_err}", "warning")
else:
self.log_message("所有分区已成功卸载", "success")
self.log_message("所有分区已成功卸载", "success")
self.log_message("--- GRUB 修复过程结束 ---", "info")
self.log_message("=" * 60, "step")
self.log_message("GRUB 修复过程结束", "step")
self.log_message("=" * 60, "step")
# 最终结果提示
# 最终结果
if repair_ok and unmount_ok:
self.master.after(0, lambda: messagebox.showinfo("修复成功", "GRUB引导修复已完成请重启系统以验证。"))
self.master.after(0, lambda: messagebox.showinfo(
"修复成功",
"GRUB 引导修复已完成!\n\n"
"请重启系统以验证修复结果。\n"
"如果仍无法启动请检查BIOS设置中的启动顺序。"
))
elif repair_ok and not unmount_ok:
self.master.after(0, lambda: messagebox.showwarning("修复完成,但有警告", "GRUB引导修复已完成但部分分区卸载失败。请重启系统以验证。"))
self.master.after(0, lambda: messagebox.showwarning(
"修复完成,但有警告",
"GRUB 引导修复已完成,但部分分区卸载失败。\n"
"建议重启系统,这会自动清理挂载点。"
))
else:
self.master.after(0, lambda: messagebox.showerror("修复失败", "GRUB引导修复过程中发生错误。请检查日志并尝试手动修复。"))
self.master.after(0, lambda: messagebox.showerror(
"修复失败",
"GRUB 引导修复过程中发生错误。\n"
"请检查日志并尝试手动修复。"
))
except Exception as e:
self.log_message(f"发生未预期的错误: {str(e)}", "error")
import traceback
self.log_message(traceback.format_exc(), "debug")
self.master.after(0, lambda err=str(e): messagebox.showerror("错误", f"修复过程中发生错误:\n{err}"))
finally:
# 确保按钮状态被恢复
# 恢复按钮状态
self.master.after(0, lambda: self.start_button.config(state="normal"))
self.master.after(0, lambda: self.rescan_button.config(state="normal"))
if __name__ == "__main__":
root = tk.Tk()
app = GrubRepairApp(root)
root.mainloop()

967
tip/main.py Normal file
View File

@@ -0,0 +1,967 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# === This file is part of Calamares - <https://calamares.io> ===
#
# SPDX-FileCopyrightText: 2014 Aurélien Gâteau <agateau@kde.org>
# SPDX-FileCopyrightText: 2014 Anke Boersma <demm@kaosx.us>
# SPDX-FileCopyrightText: 2014 Daniel Hillenbrand <codeworkx@bbqlinux.org>
# SPDX-FileCopyrightText: 2014 Benjamin Vaudour <benjamin.vaudour@yahoo.fr>
# SPDX-FileCopyrightText: 2014-2019 Kevin Kofler <kevin.kofler@chello.at>
# SPDX-FileCopyrightText: 2015-2018 Philip Mueller <philm@manjaro.org>
# SPDX-FileCopyrightText: 2016-2017 Teo Mrnjavac <teo@kde.org>
# SPDX-FileCopyrightText: 2017 Alf Gaida <agaida@siduction.org>
# SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot <groot@kde.org>
# SPDX-FileCopyrightText: 2017 Gabriel Craciunescu <crazy@frugalware.org>
# SPDX-FileCopyrightText: 2017 Ben Green <Bezzy1999@hotmail.com>
# SPDX-FileCopyrightText: 2021 Neal Gompa <ngompa13@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Calamares is Free Software: see the License-Identifier above.
#
import fileinput
import os
import re
import shutil
import subprocess
import libcalamares
from libcalamares.utils import check_target_env_call, check_target_env_output
import gettext
_ = gettext.translation("calamares-python",
localedir=libcalamares.utils.gettext_path(),
languages=libcalamares.utils.gettext_languages(),
fallback=True).gettext
# This is the sanitizer used all over to tidy up filenames
# to make identifiers (or to clean up names to make filenames).
file_name_sanitizer = str.maketrans(" /()", "_-__")
def pretty_name():
return _("Install bootloader.")
def get_uuid():
"""
Checks and passes 'uuid' to other routine.
:return:
"""
partitions = libcalamares.globalstorage.value("partitions")
for partition in partitions:
if partition["mountPoint"] == "/":
libcalamares.utils.debug("Root partition uuid: \"{!s}\"".format(partition["uuid"]))
return partition["uuid"]
return ""
def get_kernel_line(kernel_type):
"""
Passes 'kernel_line' to other routine based on configuration file.
:param kernel_type:
:return:
"""
if kernel_type == "fallback":
if "fallbackKernelLine" in libcalamares.job.configuration:
return libcalamares.job.configuration["fallbackKernelLine"]
else:
return " (fallback)"
else:
if "kernelLine" in libcalamares.job.configuration:
return libcalamares.job.configuration["kernelLine"]
else:
return ""
def get_zfs_root():
"""
Looks in global storage to find the zfs root
:return: A string containing the path to the zfs root or None if it is not found
"""
zfs = libcalamares.globalstorage.value("zfsDatasets")
if not zfs:
libcalamares.utils.warning("Failed to locate zfs dataset list")
return None
# Find the root dataset
for dataset in zfs:
try:
if dataset["mountpoint"] == "/":
return dataset["zpool"] + "/" + dataset["dsName"]
except KeyError:
# This should be impossible
libcalamares.utils.warning("Internal error handling zfs dataset")
raise
return None
def is_btrfs_root(partition):
""" Returns True if the partition object refers to a btrfs root filesystem
:param partition: A partition map from global storage
:return: True if btrfs and root, False otherwise
"""
return partition["mountPoint"] == "/" and partition["fs"] == "btrfs"
def is_zfs_root(partition):
""" Returns True if the partition object refers to a zfs root filesystem
:param partition: A partition map from global storage
:return: True if zfs and root, False otherwise
"""
return partition["mountPoint"] == "/" and partition["fs"] == "zfs"
def have_program_in_target(program : str):
"""Returns @c True if @p program is in path in the target"""
return libcalamares.utils.target_env_call(["/usr/bin/which", program]) == 0
def get_kernel_params(uuid):
# Configured kernel parameters (default "quiet"), if plymouth installed, add splash
# screen parameter and then "rw".
kernel_params = libcalamares.job.configuration.get("kernelParams", ["quiet"])
if have_program_in_target("plymouth"):
kernel_params.append("splash")
kernel_params.append("rw")
use_systemd_naming = have_program_in_target("dracut") or (libcalamares.utils.target_env_call(["/usr/bin/grep", "-q", "^HOOKS.*systemd", "/etc/mkinitcpio.conf"]) == 0)
partitions = libcalamares.globalstorage.value("partitions")
cryptdevice_params = []
swap_uuid = ""
swap_outer_mappername = None
swap_outer_uuid = None
# Take over swap settings:
# - unencrypted swap partition sets swap_uuid
# - encrypted root sets cryptdevice_params
for partition in partitions:
if partition["fs"] == "linuxswap" and not partition.get("claimed", None):
# Skip foreign swap
continue
has_luks = "luksMapperName" in partition
if partition["fs"] == "linuxswap" and not has_luks:
swap_uuid = partition["uuid"]
if partition["fs"] == "linuxswap" and has_luks:
swap_outer_mappername = partition["luksMapperName"]
swap_outer_uuid = partition["luksUuid"]
if partition["mountPoint"] == "/" and has_luks:
if use_systemd_naming:
cryptdevice_params = [f"rd.luks.uuid={partition['luksUuid']}"]
else:
cryptdevice_params = [f"cryptdevice=UUID={partition['luksUuid']}:{partition['luksMapperName']}"]
cryptdevice_params.append(f"root=/dev/mapper/{partition['luksMapperName']}")
# btrfs and zfs handling
for partition in partitions:
# If a btrfs root subvolume wasn't set, it means the root is directly on the partition
# and this option isn't needed
if is_btrfs_root(partition):
btrfs_root_subvolume = libcalamares.globalstorage.value("btrfsRootSubvolume")
if btrfs_root_subvolume:
kernel_params.append("rootflags=subvol=" + btrfs_root_subvolume)
# zfs needs to be told the location of the root dataset
if is_zfs_root(partition):
zfs_root_path = get_zfs_root()
if zfs_root_path is not None:
kernel_params.append("root=ZFS=" + zfs_root_path)
else:
# Something is really broken if we get to this point
libcalamares.utils.warning("Internal error handling zfs dataset")
raise Exception("Internal zfs data missing, please contact your distribution")
if cryptdevice_params:
kernel_params.extend(cryptdevice_params)
else:
kernel_params.append("root=UUID={!s}".format(uuid))
if swap_uuid:
kernel_params.append("resume=UUID={!s}".format(swap_uuid))
if use_systemd_naming and swap_outer_uuid:
kernel_params.append(f"rd.luks.uuid={swap_outer_uuid}")
if swap_outer_mappername:
kernel_params.append(f"resume=/dev/mapper/{swap_outer_mappername}")
return kernel_params
def create_systemd_boot_conf(installation_root_path, efi_dir, uuid, kernel, kernel_version):
"""
Creates systemd-boot configuration files based on given parameters.
:param installation_root_path: A string containing the absolute path to the root of the installation
:param efi_dir: A string containing the path to the efi dir relative to the root of the installation
:param uuid: A string containing the UUID of the root volume
:param kernel: A string containing the path to the kernel relative to the root of the installation
:param kernel_version: The kernel version string
"""
# Get the kernel params and write them to /etc/kernel/cmdline
# This file is used by kernel-install
kernel_params = " ".join(get_kernel_params(uuid))
kernel_cmdline_path = os.path.join(installation_root_path, "etc", "kernel")
os.makedirs(kernel_cmdline_path, exist_ok=True)
with open(os.path.join(kernel_cmdline_path, "cmdline"), "w") as cmdline_file:
cmdline_file.write(kernel_params)
libcalamares.utils.debug(f"Configuring kernel version {kernel_version}")
# get the machine-id
with open(os.path.join(installation_root_path, "etc", "machine-id"), 'r') as machineid_file:
machine_id = machineid_file.read().rstrip('\n')
# Ensure the directory exists
machine_dir = os.path.join(installation_root_path + efi_dir, machine_id)
os.makedirs(machine_dir, exist_ok=True)
# Call kernel-install for each kernel
libcalamares.utils.target_env_process_output(["kernel-install",
"add",
kernel_version,
os.path.join("/", kernel)])
def create_loader(loader_path, installation_root_path):
"""
Writes configuration for loader.
:param loader_path: The absolute path to the loader.conf file
:param installation_root_path: The path to the root of the target installation
"""
# get the machine-id
with open(os.path.join(installation_root_path, "etc", "machine-id"), 'r') as machineid_file:
machine_id = machineid_file.read().rstrip('\n')
try:
loader_entries = libcalamares.job.configuration["loaderEntries"]
except KeyError:
libcalamares.utils.debug("No aditional loader entries found in config")
loader_entries = []
pass
lines = [f"default {machine_id}*"]
lines.extend(loader_entries)
with open(loader_path, 'w') as loader_file:
for line in lines:
loader_file.write(line + "\n")
class SuffixIterator(object):
"""
Wrapper for one of the "generator" classes below to behave like
a proper Python iterator. The iterator is initialized with a
maximum number of attempts to generate a new suffix.
"""
def __init__(self, attempts, generator):
self.generator = generator
self.attempts = attempts
self.counter = 0
def __iter__(self):
return self
def __next__(self):
self.counter += 1
if self.counter <= self.attempts:
return self.generator.next()
raise StopIteration
class serialEfi(object):
"""
EFI Id generator that appends a serial number to the given name.
"""
def __init__(self, name):
self.name = name
# So the first call to next() will bump it to 0
self.counter = -1
def next(self):
self.counter += 1
if self.counter > 0:
return "{!s}{!s}".format(self.name, self.counter)
else:
return self.name
def render_in_base(value, base_values, length=-1):
"""
Renders @p value in base-N, where N is the number of
items in @p base_values. When rendering, use the items
of @p base_values (e.g. use "0123456789" to get regular decimal
rendering, or "ABCDEFGHIJ" for letters-as-numbers 'encoding').
If length is positive, pads out to at least that long with
leading "zeroes", whatever base_values[0] is.
"""
if value < 0:
raise ValueError("Cannot render negative values")
if len(base_values) < 2:
raise ValueError("Insufficient items for base-N rendering")
if length < 1:
length = 1
digits = []
base = len(base_values)
while value > 0:
place = value % base
value = value // base
digits.append(base_values[place])
while len(digits) < length:
digits.append(base_values[0])
return "".join(reversed(digits))
class randomEfi(object):
"""
EFI Id generator that appends a random 4-digit hex number to the given name.
"""
def __init__(self, name):
self.name = name
# So the first call to next() will bump it to 0
self.counter = -1
def next(self):
self.counter += 1
if self.counter > 0:
import random
v = random.randint(0, 65535) # 16 bits
return "{!s}{!s}".format(self.name, render_in_base(v, "0123456789ABCDEF", 4))
else:
return self.name
class phraseEfi(object):
"""
EFI Id generator that appends a random phrase to the given name.
"""
words = ("Sun", "Moon", "Mars", "Soyuz", "Falcon", "Kuaizhou", "Gaganyaan")
def __init__(self, name):
self.name = name
# So the first call to next() will bump it to 0
self.counter = -1
def next(self):
self.counter += 1
if self.counter > 0:
import random
desired_length = 1 + self.counter // 5
v = random.randint(0, len(self.words) ** desired_length)
return "{!s}{!s}".format(self.name, render_in_base(v, self.words))
else:
return self.name
def get_efi_suffix_generator(name):
"""
Handle EFI bootloader Ids with ${<something>} for suffix-processing.
"""
if "${" not in name:
raise ValueError("Misplaced call to get_efi_suffix_generator, no ${}")
if not name.endswith("}"):
raise ValueError("Misplaced call to get_efi_suffix_generator, no trailing ${}")
if name.count("${") > 1:
raise ValueError("EFI ID {!r} contains multiple generators".format(name))
import re
prefix, generator_name = re.match("(.*)\${([^}]*)}$", name).groups()
if generator_name not in ("SERIAL", "RANDOM", "PHRASE"):
raise ValueError("EFI suffix {!r} is unknown".format(generator_name))
generator = None
if generator_name == "SERIAL":
generator = serialEfi(prefix)
elif generator_name == "RANDOM":
generator = randomEfi(prefix)
elif generator_name == "PHRASE":
generator = phraseEfi(prefix)
if generator is None:
raise ValueError("EFI suffix {!r} is unsupported".format(generator_name))
return generator
def change_efi_suffix(efi_directory, bootloader_id):
"""
Returns a label based on @p bootloader_id that is usable within
@p efi_directory. If there is a ${<something>} suffix marker
in the given id, tries to generate a unique label.
"""
if bootloader_id.endswith("}") and "${" in bootloader_id:
# Do 10 attempts with any suffix generator
g = SuffixIterator(10, get_efi_suffix_generator(bootloader_id))
else:
# Just one attempt
g = [bootloader_id]
for candidate_name in g:
if not os.path.exists(os.path.join(efi_directory, candidate_name)):
return candidate_name
return bootloader_id
def efi_label(efi_directory):
"""
Returns a sanitized label, possibly unique, that can be
used within @p efi_directory.
"""
if "efiBootloaderId" in libcalamares.job.configuration:
efi_bootloader_id = change_efi_suffix(efi_directory, libcalamares.job.configuration["efiBootloaderId"])
else:
branding = libcalamares.globalstorage.value("branding")
efi_bootloader_id = branding["bootloaderEntryName"]
return efi_bootloader_id.translate(file_name_sanitizer)
def efi_word_size():
# get bitness of the underlying UEFI
try:
sysfile = open("/sys/firmware/efi/fw_platform_size", "r")
efi_bitness = sysfile.read(2)
except Exception:
# if the kernel is older than 4.0, the UEFI bitness likely isn't
# exposed to the userspace so we assume a 64 bit UEFI here
efi_bitness = "64"
return efi_bitness
def efi_boot_next():
"""
Tell EFI to definitely boot into the just-installed
system next time.
"""
boot_mgr = libcalamares.job.configuration["efiBootMgr"]
boot_entry = None
efi_bootvars = subprocess.check_output([boot_mgr], universal_newlines=True)
for line in efi_bootvars.split('\n'):
if not line:
continue
words = line.split()
if len(words) >= 2 and words[0] == "BootOrder:":
boot_entry = words[1].split(',')[0]
break
if boot_entry:
subprocess.call([boot_mgr, "-n", boot_entry])
def get_kernels(installation_root_path):
"""
Gets a list of kernels and associated values for each kernel. This will work as is for many distros.
If not, it should be safe to modify it to better support your distro
:param installation_root_path: A string with the absolute path to the root of the installation
Returns a list of 3-tuples
Each 3-tuple contains the kernel, kernel_type and kernel_version
"""
try:
kernel_search_path = libcalamares.job.configuration["kernelSearchPath"]
except KeyError:
libcalamares.utils.warning("No kernel pattern found in configuration, using '/usr/lib/modules'")
kernel_search_path = "/usr/lib/modules"
pass
kernel_list = []
try:
kernel_pattern = libcalamares.job.configuration["kernelPattern"]
except KeyError:
libcalamares.utils.warning("No kernel pattern found in configuration, using 'vmlinuz'")
kernel_pattern = "vmlinuz"
pass
# find all the installed kernels
for root, dirs, files in os.walk(os.path.join(installation_root_path, kernel_search_path.lstrip('/'))):
for file in files:
if re.search(kernel_pattern, file):
rel_root = os.path.relpath(root, installation_root_path)
kernel_list.append((os.path.join(rel_root, file), "default", os.path.basename(root)))
return kernel_list
def install_clr_boot_manager():
"""
Installs clr-boot-manager as the bootloader for EFI systems
"""
libcalamares.utils.debug("Bootloader: clr-boot-manager")
installation_root_path = libcalamares.globalstorage.value("rootMountPoint")
kernel_config_path = os.path.join(installation_root_path, "etc", "kernel")
os.makedirs(kernel_config_path, exist_ok=True)
cmdline_path = os.path.join(kernel_config_path, "cmdline")
# Get the kernel params
uuid = get_uuid()
kernel_params = " ".join(get_kernel_params(uuid))
# Write out the cmdline file for clr-boot-manager
with open(cmdline_path, "w") as cmdline_file:
cmdline_file.write(kernel_params)
check_target_env_call(["clr-boot-manager", "update"])
def install_systemd_boot(efi_directory):
"""
Installs systemd-boot as bootloader for EFI setups.
:param efi_directory:
"""
libcalamares.utils.debug("Bootloader: systemd-boot")
installation_root_path = libcalamares.globalstorage.value("rootMountPoint")
install_efi_directory = installation_root_path + efi_directory
uuid = get_uuid()
loader_path = os.path.join(install_efi_directory,
"loader",
"loader.conf")
subprocess.call(["bootctl",
"--path={!s}".format(install_efi_directory),
"install"])
for (kernel, kernel_type, kernel_version) in get_kernels(installation_root_path):
create_systemd_boot_conf(installation_root_path,
efi_directory,
uuid,
kernel,
kernel_version)
create_loader(loader_path, installation_root_path)
def get_grub_efi_parameters():
"""
Returns a 3-tuple of suitable parameters for GRUB EFI installation,
depending on the host machine architecture. The return is
- target name
- grub.efi name
- boot.efi name
all three are strings. May return None if there is no suitable
set for the current machine. May return unsuitable values if the
host architecture is unknown (e.g. defaults to x86_64).
"""
import platform
efi_bitness = efi_word_size()
cpu_type = platform.machine()
if efi_bitness == "32":
# Assume all 32-bitters are legacy x86
return "i386-efi", "grubia32.efi", "bootia32.efi"
elif efi_bitness == "64" and cpu_type == "aarch64":
return "arm64-efi", "grubaa64.efi", "bootaa64.efi"
elif efi_bitness == "64" and cpu_type == "loongarch64":
return "loongarch64-efi", "grubloongarch64.efi", "bootloongarch64.efi"
elif efi_bitness == "64":
# If it's not ARM, must by AMD64
return "x86_64-efi", "grubx64.efi", "bootx64.efi"
libcalamares.utils.warning(
"Could not find GRUB parameters for bits {b} and cpu {c}".format(b=repr(efi_bitness), c=repr(cpu_type)))
return None
def run_grub_mkconfig(partitions, output_file):
"""
Runs grub-mkconfig in the target environment
:param partitions: The partitions list from global storage
:param output_file: A string containing the path to the generating grub config file
:return:
"""
# zfs needs an environment variable set for grub-mkconfig
if any([is_zfs_root(partition) for partition in partitions]):
check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 " +
libcalamares.job.configuration["grubMkconfig"] + " -o " + output_file])
else:
# The input file /etc/default/grub should already be filled out by the
# grubcfg job module.
check_target_env_call([libcalamares.job.configuration["grubMkconfig"], "-o", output_file])
def run_grub_install(fw_type, partitions, efi_directory, install_hybrid_grub):
"""
Runs grub-install in the target environment
:param fw_type: A string which is "efi" for UEFI installs. Any other value results in a BIOS install
:param partitions: The partitions list from global storage
:param efi_directory: The path of the efi directory relative to the root of the install
:return:
"""
libcalamares.utils.debug("run_grub_install------")
is_zfs = any([is_zfs_root(partition) for partition in partitions])
# zfs needs an environment variable set for grub
if is_zfs:
check_target_env_call(["sh", "-c", "echo ZPOOL_VDEV_NAME_PATH=1 >> /etc/environment"])
if fw_type == "efi":
assert efi_directory is not None
efi_bootloader_id = efi_label(efi_directory)
efi_target, efi_grub_file, efi_boot_file = get_grub_efi_parameters()
if is_zfs:
check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 " + libcalamares.job.configuration["grubInstall"]
+ " --target=" + efi_target + " --efi-directory=" + efi_directory
+ " --bootloader-id=" + efi_bootloader_id + " --force"])
else:
check_target_env_call([libcalamares.job.configuration["grubInstall"],
"--target=" + efi_target,
"--efi-directory=" + efi_directory,
"--bootloader-id=" + efi_bootloader_id,
"--force"])
else:
if libcalamares.globalstorage.value("bootLoader") is None and install_hybrid_grub:
efi_install_path = libcalamares.globalstorage.value("efiSystemPartition")
if efi_install_path is None or efi_install_path == "":
efi_install_path = "/boot/efi"
find_esp_disk_command = f"lsblk -o PKNAME \"$(df --output=source '{efi_install_path}' | tail -n1)\""
boot_loader_install_path = check_target_env_output(["sh", "-c", find_esp_disk_command]).strip()
if not "\n" in boot_loader_install_path:
libcalamares.utils.warning(_("Cannot find the drive containing the EFI system partition!"))
return
boot_loader_install_path = "/dev/" + boot_loader_install_path.split("\n")[1]
else:
boot_loader = libcalamares.globalstorage.value("bootLoader")
boot_loader_install_path = boot_loader["installPath"]
libcalamares.utils.debug(f"boot_loader_install_path: {boot_loader_install_path}")
if boot_loader_install_path is None:
libcalamares.utils.debug(f"boot_loader_install_path-none: {boot_loader_install_path}")
return
# boot_loader_install_path points to the physical disk to install GRUB
# to. It should start with "/dev/", and be at least as long as the
# string "/dev/sda".
if not boot_loader_install_path.startswith("/dev/") or len(boot_loader_install_path) < 8:
raise ValueError(f"boot_loader_install_path contains unexpected value '{boot_loader_install_path}'")
if is_zfs:
check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 "
+ libcalamares.job.configuration["grubInstall"]
+ " --target=i386-pc --recheck --force "
+ boot_loader_install_path])
else:
libcalamares.utils.debug(f"grubInstall-boot_loader_install_path: {boot_loader_install_path}")
check_target_env_call([libcalamares.job.configuration["grubInstall"],
"--target=i386-pc",
"--recheck",
"--force",
boot_loader_install_path])
def install_grub(efi_directory, fw_type, install_hybrid_grub):
"""
Installs grub as bootloader, either in pc or efi mode.
:param efi_directory:
:param fw_type:
:param install_hybrid_grub:
"""
# get the partition from global storage
partitions = libcalamares.globalstorage.value("partitions")
if not partitions:
libcalamares.utils.warning(_("Failed to install grub, no partitions defined in global storage"))
return
if fw_type != "bios" and fw_type != "efi":
raise ValueError("fw_type must be 'bios' or 'efi'")
if fw_type == "efi" or install_hybrid_grub:
libcalamares.utils.debug("Bootloader: grub (efi)")
libcalamares.utils.debug(f"install_hybrid_grub: {install_hybrid_grub}")
installation_root_path = libcalamares.globalstorage.value("rootMountPoint")
install_efi_directory = installation_root_path + efi_directory
if not os.path.isdir(install_efi_directory):
os.makedirs(install_efi_directory)
efi_bootloader_id = efi_label(efi_directory)
efi_target, efi_grub_file, efi_boot_file = get_grub_efi_parameters()
run_grub_install("efi", partitions, efi_directory, install_hybrid_grub)
# VFAT is weird, see issue CAL-385
install_efi_directory_firmware = (vfat_correct_case(
install_efi_directory,
"EFI"))
if not os.path.exists(install_efi_directory_firmware):
os.makedirs(install_efi_directory_firmware)
# there might be several values for the boot directory
# most usual they are boot, Boot, BOOT
install_efi_boot_directory = (vfat_correct_case(
install_efi_directory_firmware,
"boot"))
if not os.path.exists(install_efi_boot_directory):
os.makedirs(install_efi_boot_directory)
# Workaround for some UEFI firmwares
fallback = "installEFIFallback"
libcalamares.utils.debug("UEFI Fallback: " + str(libcalamares.job.configuration.get(fallback, "<unset>")))
if libcalamares.job.configuration.get(fallback, True):
libcalamares.utils.debug(" .. installing '{!s}' fallback firmware".format(efi_boot_file))
efi_file_source = os.path.join(install_efi_directory_firmware,
efi_bootloader_id,
efi_grub_file)
efi_file_target = os.path.join(install_efi_boot_directory, efi_boot_file)
shutil.copy2(efi_file_source, efi_file_target)
if fw_type == "bios" or install_hybrid_grub:
libcalamares.utils.debug("Bootloader: grub (bios)")
run_grub_install("bios", partitions, efi_directory, install_hybrid_grub)
run_grub_mkconfig(partitions, libcalamares.job.configuration["grubCfg"])
def install_secureboot(efi_directory):
"""
Installs the secureboot shim in the system by calling efibootmgr.
"""
efi_bootloader_id = efi_label(efi_directory)
installation_root_path = libcalamares.globalstorage.value("rootMountPoint")
install_efi_directory = installation_root_path + efi_directory
if efi_word_size() == "64":
install_efi_bin = "shimx64.efi"
elif efi_word_size() == "32":
install_efi_bin = "shimia32.efi"
else:
libcalamares.utils.warning(f"Unknown efi word size of {efi_word_size()} found")
return None
# Copied, roughly, from openSUSE's install script,
# and pythonified. *disk* is something like /dev/sda,
# while *drive* may return "(disk/dev/sda,gpt1)" ..
# we're interested in the numbers in the second part
# of that tuple.
efi_drive = subprocess.check_output([
libcalamares.job.configuration["grubProbe"],
"-t", "drive", "--device-map=", install_efi_directory]).decode("ascii")
efi_disk = subprocess.check_output([
libcalamares.job.configuration["grubProbe"],
"-t", "disk", "--device-map=", install_efi_directory]).decode("ascii")
efi_drive_partition = efi_drive.replace("(", "").replace(")", "").split(",")[1]
# Get the first run of digits from the partition
efi_partition_number = None
c = 0
start = None
while c < len(efi_drive_partition):
if efi_drive_partition[c].isdigit() and start is None:
start = c
if not efi_drive_partition[c].isdigit() and start is not None:
efi_partition_number = efi_drive_partition[start:c]
break
c += 1
if efi_partition_number is None:
raise ValueError("No partition number found for %s" % install_efi_directory)
subprocess.call([
libcalamares.job.configuration["efiBootMgr"],
"-c",
"-w",
"-L", efi_bootloader_id,
"-d", efi_disk,
"-p", efi_partition_number,
"-l", install_efi_directory + "/" + install_efi_bin])
efi_boot_next()
# The input file /etc/default/grub should already be filled out by the
# grubcfg job module.
check_target_env_call([libcalamares.job.configuration["grubMkconfig"],
"-o", os.path.join(efi_directory, "EFI",
efi_bootloader_id, "grub.cfg")])
def vfat_correct_case(parent, name):
for candidate in os.listdir(parent):
if name.lower() == candidate.lower():
return os.path.join(parent, candidate)
return os.path.join(parent, name)
def efi_partitions(efi_boot_path):
"""
The (one) partition mounted on @p efi_boot_path, or an empty list.
"""
return [p for p in libcalamares.globalstorage.value("partitions") if p["mountPoint"] == efi_boot_path]
def update_refind_config(efi_directory, installation_root_path):
"""
:param efi_directory: The path to the efi directory relative to the root
:param installation_root_path: The path to the root of the installation
"""
try:
kernel_list = libcalamares.job.configuration["refindKernelList"]
except KeyError:
libcalamares.utils.warning('refindKernelList not set. Skipping updating refind.conf')
return
# Update the config in the file
for line in fileinput.input(installation_root_path + efi_directory + "/EFI/refind/refind.conf", inplace=True):
line = line.strip()
if line.startswith("#extra_kernel_version_strings") or line.startswith("extra_kernel_version_strings"):
line = line.lstrip("#")
for kernel in kernel_list:
if kernel not in line:
line += "," + kernel
print(line)
def install_refind(efi_directory):
try:
installation_root_path = libcalamares.globalstorage.value("rootMountPoint")
except KeyError:
libcalamares.utils.warning('Global storage value "rootMountPoint" missing')
install_efi_directory = installation_root_path + efi_directory
uuid = get_uuid()
kernel_params = " ".join(get_kernel_params(uuid))
conf_path = os.path.join(installation_root_path, "boot/refind_linux.conf")
check_target_env_call(["refind-install"])
with open(conf_path, "r") as refind_file:
filedata = [x.strip() for x in refind_file.readlines()]
with open(conf_path, 'w') as refind_file:
for line in filedata:
if line.startswith('"Boot with standard options"'):
line = f'"Boot with standard options" "{kernel_params}"'
elif line.startswith('"Boot to single-user mode"'):
line = f'"Boot to single-user mode" "{kernel_params}" single'
refind_file.write(line + "\n")
update_refind_config(efi_directory, installation_root_path)
def prepare_bootloader(fw_type, install_hybrid_grub):
"""
Prepares bootloader.
Based on value 'efi_boot_loader', it either calls systemd-boot
or grub to be installed.
:param fw_type:
:return:
"""
# Get the boot loader selection from global storage if it is set in the config file
try:
gs_name = libcalamares.job.configuration["efiBootLoaderVar"]
if libcalamares.globalstorage.contains(gs_name):
efi_boot_loader = libcalamares.globalstorage.value(gs_name)
else:
libcalamares.utils.warning(
f"Specified global storage value not found in global storage")
return None
except KeyError:
# If the conf value for using global storage is not set, use the setting from the config file.
try:
efi_boot_loader = libcalamares.job.configuration["efiBootLoader"]
except KeyError:
if fw_type == "efi" or install_hybrid_grub:
libcalamares.utils.warning("Configuration missing both efiBootLoader and efiBootLoaderVar on an EFI-enabled "
"system, bootloader not installed")
return
else:
pass
# If the user has selected not to install bootloader, bail out here
if efi_boot_loader.casefold() == "none":
libcalamares.utils.debug("Skipping bootloader installation since no bootloader was selected")
return None
efi_directory = libcalamares.globalstorage.value("efiSystemPartition")
if efi_boot_loader == "clr-boot-manager":
if fw_type != "efi":
# Grub has to be installed first on non-EFI systems
install_grub(efi_directory, fw_type)
install_clr_boot_manager()
elif efi_boot_loader == "systemd-boot" and fw_type == "efi":
install_systemd_boot(efi_directory)
elif efi_boot_loader == "sb-shim" and fw_type == "efi":
install_secureboot(efi_directory)
elif efi_boot_loader == "refind" and fw_type == "efi":
install_refind(efi_directory)
elif efi_boot_loader == "grub" or fw_type != "efi":
install_grub(efi_directory, fw_type, install_hybrid_grub)
else:
libcalamares.utils.debug("WARNING: the combination of "
"boot-loader '{!s}' and firmware '{!s}' "
"is not supported.".format(efi_boot_loader, fw_type))
def run():
"""
Starts procedure and passes 'fw_type' to other routine.
:return:
"""
fw_type = libcalamares.globalstorage.value("firmwareType")
boot_loader = libcalamares.globalstorage.value("bootLoader")
install_hybrid_grub = libcalamares.job.configuration.get("installHybridGRUB", False)
efi_boot_loader = libcalamares.job.configuration.get("efiBootLoader", "")
if install_hybrid_grub == True and efi_boot_loader != "grub":
raise ValueError(f"efi_boot_loader '{efi_boot_loader}' is illegal when install_hybrid_grub is 'true'!")
if boot_loader is None and fw_type != "efi":
libcalamares.utils.warning("Non-EFI system, and no bootloader is set.")
return None
partitions = libcalamares.globalstorage.value("partitions")
if fw_type == "efi":
efi_system_partition = libcalamares.globalstorage.value("efiSystemPartition")
esp_found = [p for p in partitions if p["mountPoint"] == efi_system_partition]
if not esp_found:
libcalamares.utils.warning("EFI system, but nothing mounted on {!s}".format(efi_system_partition))
return None
try:
prepare_bootloader(fw_type, install_hybrid_grub)
except subprocess.CalledProcessError as e:
libcalamares.utils.warning(str(e))
libcalamares.utils.debug("stdout:" + str(e.stdout))
libcalamares.utils.debug("stderr:" + str(e.stderr))
return (_("Bootloader installation error"),
_("The bootloader could not be installed. The installation command <pre>{!s}</pre> returned error "
"code {!s}.")
.format(e.cmd, e.returncode))
return None