diff --git a/AGENTS.md b/AGENTS.md index 2f59a96..599101a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_` 格式 -- **依赖命令**: 需要系统中存在 `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. **日志持久化**: 将日志保存到文件 diff --git a/__pycache__/backend.cpython-39.pyc b/__pycache__/backend.cpython-39.pyc index edcd0e5..7a03b56 100644 Binary files a/__pycache__/backend.cpython-39.pyc and b/__pycache__/backend.cpython-39.pyc differ diff --git a/backend.py b/backend.py index 7124096..f5734d0 100644 --- a/backend.py +++ b/backend.py @@ -1,4 +1,6 @@ # backend.py +# 参考 Calamares 引导安装模块优化 +# 支持多架构、EFI fallback、Secure Boot、特殊文件系统等 import subprocess import os @@ -7,7 +9,9 @@ import time import re import shutil import glob -from typing import Tuple, List, Dict, Optional +import platform +from typing import Tuple, List, Dict, Optional, Callable +from enum import Enum # 常量配置 COMMAND_TIMEOUT = 300 # 命令执行超时时间(秒) @@ -16,62 +20,151 @@ DEFAULT_GRUB_CONFIG_PATHS = [ "/boot/grub2/grub.cfg", ] +# EFI 架构参数映射 (参考 Calamares) +EFI_ARCH_PARAMETERS = { + "32": { + "target": "i386-efi", + "grub_file": "grubia32.efi", + "boot_file": "bootia32.efi", + "shim_file": "shimia32.efi" + }, + "64": { + "x86_64": { + "target": "x86_64-efi", + "grub_file": "grubx64.efi", + "boot_file": "bootx64.efi", + "shim_file": "shimx64.efi" + }, + "aarch64": { + "target": "arm64-efi", + "grub_file": "grubaa64.efi", + "boot_file": "bootaa64.efi", + "shim_file": "shimaa64.efi" + }, + "loongarch64": { + "target": "loongarch64-efi", + "grub_file": "grubloongarch64.efi", + "boot_file": "bootloongarch64.efi", + "shim_file": "shimloongarch64.efi" + } + } +} + + +class LogLevel(Enum): + """日志级别""" + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + SUCCESS = "success" + STEP = "step" + + +# 全局日志回调函数 +_log_callback: Optional[Callable[[str, LogLevel], None]] = None + + +def set_log_callback(callback: Callable[[str, LogLevel], None]): + """设置日志回调函数,用于前端显示""" + global _log_callback + _log_callback = callback + + +def _log(message: str, level: LogLevel = LogLevel.INFO): + """内部日志函数""" + # 同时输出到控制台和回调 + prefix = { + LogLevel.DEBUG: "[DEBUG]", + LogLevel.INFO: "[INFO]", + LogLevel.WARNING: "[WARN]", + LogLevel.ERROR: "[ERROR]", + LogLevel.SUCCESS: "[SUCCESS]", + LogLevel.STEP: "[STEP]" + }.get(level, "[INFO]") + + print(f"{prefix} {message}") + + if _log_callback: + try: + _log_callback(message, level) + except Exception: + pass + def log_step(step_name: str, detail: str = ""): """输出步骤日志""" separator = "=" * 60 - print(f"\n[STEP] {separator}") - print(f"[STEP] {step_name}") + _log(f"{separator}", LogLevel.STEP) + _log(f"{step_name}", LogLevel.STEP) if detail: - print(f"[STEP] 详情: {detail}") - print(f"[STEP] {separator}\n") + _log(f"详情: {detail}", LogLevel.STEP) + _log(f"{separator}", LogLevel.STEP) def log_info(msg: str): """输出信息日志""" - print(f"[INFO] {msg}") + _log(msg, LogLevel.INFO) def log_warning(msg: str): """输出警告日志""" - print(f"[WARN] {msg}") + _log(msg, LogLevel.WARNING) def log_error(msg: str): """输出错误日志""" - print(f"[ERROR] {msg}") + _log(msg, LogLevel.ERROR) def log_debug(msg: str): """输出调试日志""" - print(f"[DEBUG] {msg}") + _log(msg, LogLevel.DEBUG) + + +def log_success(msg: str): + """输出成功日志""" + _log(msg, LogLevel.SUCCESS) def validate_device_path(path: str) -> bool: """ 验证设备路径格式是否合法(防止命令注入)。 - :param path: 设备路径(如 /dev/sda1, /dev/mapper/cl-root) - :return: 是否合法 + 支持标准分区、LVM、LUKS、RAID、ZFS等设备。 """ if not path: return False patterns = [ r'^/dev/[a-zA-Z0-9_-]+$', r'^/dev/mapper/[a-zA-Z0-9_-]+$', + r'^/dev/mapper/[a-zA-Z0-9_]+-[a-zA-Z0-9_-]+$', + r'^/dev/md[0-9]+$', + r'^/dev/nvme[0-9]+n[0-9]+(p[0-9]+)?$', + r'^/dev/mmcblk[0-9]+(p[0-9]+)?$', + r'^/dev/zd[0-9]+$', ] return any(re.match(pattern, path) is not None for pattern in patterns) def run_command(command: List[str], description: str = "执行命令", cwd: Optional[str] = None, shell: bool = False, - timeout: int = COMMAND_TIMEOUT) -> Tuple[bool, str, str]: + timeout: int = COMMAND_TIMEOUT, + env: Optional[Dict[str, str]] = None) -> Tuple[bool, str, str]: """ 运行一个系统命令,并捕获其输出和错误。 + 支持环境变量设置(用于ZFS等特殊文件系统)。 """ cmd_str = ' '.join(command) log_info(f"执行命令: {cmd_str}") log_debug(f"工作目录: {cwd or '当前目录'}, 超时: {timeout}秒") + # 准备环境变量 + run_env = None + if env: + run_env = os.environ.copy() + run_env.update(env) + log_debug(f"环境变量: {env}") + try: result = subprocess.run( command, @@ -80,13 +173,14 @@ def run_command(command: List[str], description: str = "执行命令", check=True, cwd=cwd, shell=shell, - timeout=timeout + timeout=timeout, + env=run_env ) if result.stdout: - log_debug(f"stdout: {result.stdout[:500]}") # 限制输出长度 + log_debug(f"stdout: {result.stdout[:500]}") if result.stderr: log_debug(f"stderr: {result.stderr[:500]}") - log_info(f"✓ 命令成功: {description}") + log_success(f"✓ 命令成功: {description}") return True, result.stdout, result.stderr except subprocess.CalledProcessError as e: log_error(f"✗ 命令失败: {description}") @@ -105,29 +199,189 @@ def run_command(command: List[str], description: str = "执行命令", return False, "", str(e) +def get_efi_word_size() -> str: + """ + 检测 EFI 位数(32或64位)。 + 通过读取 /sys/firmware/efi/fw_platform_size 获取。 + 如果内核较旧不支持,默认假设为 64 位。 + """ + # 首先检查是否是 EFI 系统 + if not os.path.exists("/sys/firmware/efi"): + log_debug("非 EFI 系统,返回 64 位作为默认值") + return "64" + + try: + # 尝试读取新内核的 fw_platform_size + if os.path.exists("/sys/firmware/efi/fw_platform_size"): + with open("/sys/firmware/efi/fw_platform_size", "r") as f: + efi_bitness = f.read(2).strip() + if efi_bitness in ["32", "64"]: + log_debug(f"检测到 EFI 位数: {efi_bitness} 位") + return efi_bitness + + # 旧内核:检查是否存在 64 位 EFI 相关文件 + # 如果存在 /sys/firmware/efi/vars 或 /sys/firmware/efi/efivars,假设为 64 位 + if os.path.exists("/sys/firmware/efi/vars") or os.path.exists("/sys/firmware/efi/efivars"): + log_debug("检测到 EFI 环境,假设为 64 位(旧内核)") + return "64" + + except FileNotFoundError: + log_debug("无法读取 EFI 位数文件,假设为 64 位") + except Exception as e: + log_warning(f"读取 EFI 位数时出错: {e}") + + return "64" + + +def get_grub_efi_parameters() -> Optional[Tuple[str, str, str]]: + """ + 获取 GRUB EFI 安装参数。 + 返回: (target_name, grub.efi_name, boot.efi_name) + 参考 Calamares 的实现。 + """ + efi_bitness = get_efi_word_size() + cpu_type = platform.machine() + + log_debug(f"系统架构: {cpu_type}, EFI位数: {efi_bitness}") + + if efi_bitness == "32": + # 假设所有 32 位都是传统 x86 + params = EFI_ARCH_PARAMETERS["32"] + return params["target"], params["grub_file"], params["boot_file"] + elif efi_bitness == "64" and cpu_type == "aarch64": + params = EFI_ARCH_PARAMETERS["64"]["aarch64"] + return params["target"], params["grub_file"], params["boot_file"] + elif efi_bitness == "64" and cpu_type == "loongarch64": + params = EFI_ARCH_PARAMETERS["64"]["loongarch64"] + return params["target"], params["grub_file"], params["boot_file"] + elif efi_bitness == "64": + # 如果不是 ARM,则为 AMD64 + params = EFI_ARCH_PARAMETERS["64"]["x86_64"] + return params["target"], params["grub_file"], params["boot_file"] + + log_warning(f"无法确定 GRUB EFI 参数: bits={efi_bitness}, cpu={cpu_type}") + return None + + +def get_shim_filename() -> Optional[str]: + """获取当前架构的 shim 文件名""" + efi_bitness = get_efi_word_size() + cpu_type = platform.machine() + + if efi_bitness == "32": + return EFI_ARCH_PARAMETERS["32"]["shim_file"] + elif efi_bitness == "64" and cpu_type == "aarch64": + return EFI_ARCH_PARAMETERS["64"]["aarch64"]["shim_file"] + elif efi_bitness == "64" and cpu_type == "loongarch64": + return EFI_ARCH_PARAMETERS["64"]["loongarch64"]["shim_file"] + elif efi_bitness == "64": + return EFI_ARCH_PARAMETERS["64"]["x86_64"]["shim_file"] + + return None + + +def is_live_environment() -> bool: + """ + 检测是否在 Live 环境中运行。 + Live 环境通常无法访问 EFI 变量。 + """ + efivars_path = "/sys/firmware/efi/efivars" + if not os.path.exists(efivars_path): + log_debug("Live 环境检测: /sys/firmware/efi/efivars 不存在") + return True + try: + files = os.listdir(efivars_path) + if not files: + log_debug("Live 环境检测: efivars 目录为空") + return True + except PermissionError: + log_debug("Live 环境检测: 无法访问 efivars") + return True + + log_debug("Live 环境检测: 看起来不是 Live 环境") + return False + + +def check_secure_boot_status() -> Tuple[bool, bool]: + """ + 检查 Secure Boot 状态。 + 返回: (是否支持检测, 是否启用) + """ + try: + # 方法1: 通过 mokutil 检查 + success, stdout, _ = run_command( + ["mokutil", "--sb-state"], + "检查 Secure Boot 状态", + timeout=10 + ) + if success: + if "enabled" in stdout.lower(): + log_info("Secure Boot 状态: 已启用") + return True, True + elif "disabled" in stdout.lower(): + log_info("Secure Boot 状态: 已禁用") + return True, False + except Exception: + pass + + # 方法2: 通过 efi 变量检查 + try: + sb_var_path = "/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c" + if os.path.exists(sb_var_path): + with open(sb_var_path, "rb") as f: + data = f.read() + if len(data) >= 5: + # 第5个字节是 Secure Boot 状态 + sb_enabled = data[4] == 1 + log_info(f"Secure Boot 状态: {'已启用' if sb_enabled else '已禁用'}") + return True, sb_enabled + except Exception as e: + log_debug(f"通过 efi 变量检查 Secure Boot 失败: {e}") + + log_warning("无法检测 Secure Boot 状态") + return False, False + + def _process_partition(block_device: Dict, all_disks: List, all_partitions: List, all_efi_partitions: List) -> None: """ - 处理单个块设备,递归处理子分区、LVM逻辑卷等。 + 处理单个块设备,递归处理子分区、LVM逻辑卷、btrfs子卷等。 """ dev_name = f"/dev/{block_device.get('name', '')}" dev_type = block_device.get("type") + + # 跳过 loop 设备和 Live 系统自身的挂载 + mountpoint = block_device.get("mountpoint") + if mountpoint and (mountpoint.startswith("/run/media") or + mountpoint.startswith("/cdrom") or + mountpoint.startswith("/live") or + mountpoint.startswith("/iso")): + log_debug(f"跳过 Live 系统挂载点: {dev_name} -> {mountpoint}") + return if dev_type == "disk": - all_disks.append({"name": dev_name}) + # 跳过 loop 和 rom 设备 + if block_device.get("rota") is not None or block_device.get("size", "0") == "0": + pass # 可能是有效磁盘 + all_disks.append({ + "name": dev_name, + "size": block_device.get("size", "unknown"), + "model": block_device.get("model", "") # 可能为空(旧版本 lsblk) + }) for child in block_device.get("children", []): _process_partition(child, all_disks, all_partitions, all_efi_partitions) elif dev_type == "part": - mountpoint = block_device.get("mountpoint") - if mountpoint and (mountpoint == "/" or - mountpoint.startswith("/run/media") or - mountpoint.startswith("/cdrom") or - mountpoint.startswith("/live")): - for child in block_device.get("children", []): - _process_partition(child, all_disks, all_partitions, all_efi_partitions) - return - + # 跳过 Live 系统的根分区 + if mountpoint and mountpoint == "/": + # 检查是否是 Live 系统的根(通过检查是否有 /live 或特定标记) + live_marker = os.path.join(mountpoint, "live") + if os.path.exists(live_marker): + log_debug(f"跳过 Live 系统根分区: {dev_name}") + for child in block_device.get("children", []): + _process_partition(child, all_disks, all_partitions, all_efi_partitions) + return + part_info = { "name": dev_name, "fstype": block_device.get("fstype", "unknown"), @@ -136,25 +390,57 @@ def _process_partition(block_device: Dict, all_disks: List, all_partitions: List "uuid": block_device.get("uuid"), "partlabel": block_device.get("partlabel"), "label": block_device.get("label"), - "parttype": (block_device.get("parttype") or "").upper() + "parttype": (block_device.get("parttype") or "").upper(), + "fsver": block_device.get("fsver", ""), + "is_subvolume": False } + + # 检测 btrfs 子卷 + if part_info["fstype"] == "btrfs": + part_info["is_btrfs"] = True + # 尝试获取子卷信息 + try: + success, stdout, _ = run_command( + ["btrfs", "subvolume", "list", dev_name], + f"检测 btrfs 子卷: {dev_name}", + timeout=10 + ) + if success: + subvolumes = [] + for line in stdout.strip().split('\n'): + if line: + # 格式: ID 256 gen 16 top level 5 path @ + match = re.search(r'path\s+(\S+)', line) + if match: + subvolumes.append(match.group(1)) + if subvolumes: + part_info["subvolumes"] = subvolumes + log_debug(f"{dev_name} 的 btrfs 子卷: {subvolumes}") + except Exception as e: + log_debug(f"检测 btrfs 子卷失败: {e}") + all_partitions.append(part_info) - + + # 检测 EFI 系统分区 is_vfat = part_info["fstype"] == "vfat" has_efi_label = part_info["partlabel"] and "EFI" in part_info["partlabel"].upper() has_efi_name = part_info["label"] and "EFI" in part_info["label"].upper() + # EFI 系统分区的 GPT 类型 GUID is_efi_type = part_info["parttype"] == "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" + # 或者检测标志 + is_efi_fs = block_device.get("fsver") == "FAT32" and is_vfat if is_vfat and (has_efi_label or has_efi_name or is_efi_type): all_efi_partitions.append(part_info) + log_debug(f"检测到 EFI 分区: {dev_name}") for child in block_device.get("children", []): _process_partition(child, all_disks, all_partitions, all_efi_partitions) elif dev_type in ["lvm", "dm"]: - mountpoint = block_device.get("mountpoint") lv_name = block_device.get("name", "") + # 处理 LVM 命名格式 (vg-lv) if "-" in lv_name and not lv_name.startswith("dm-"): mapper_path = f"/dev/mapper/{lv_name}" else: @@ -172,6 +458,28 @@ def _process_partition(block_device: Dict, all_disks: List, all_partitions: List "is_lvm": True, "dm_name": lv_name } + + # 检测 LUKS 加密 + if part_info["fstype"] == "crypto_LUKS": + part_info["is_luks"] = True + log_debug(f"检测到 LUKS 加密卷: {mapper_path}") + + all_partitions.append(part_info) + + for child in block_device.get("children", []): + _process_partition(child, all_disks, all_partitions, all_efi_partitions) + + elif dev_type == "crypt": + # 已解密的 LUKS 设备 + part_info = { + "name": dev_name, + "fstype": block_device.get("fstype", "unknown"), + "size": block_device.get("size", "unknown"), + "mountpoint": mountpoint, + "uuid": block_device.get("uuid"), + "is_luks_open": True, + "parent": block_device.get("pkname", "") # 可能为空(旧版本 lsblk) + } all_partitions.append(part_info) for child in block_device.get("children", []): @@ -181,9 +489,11 @@ def _process_partition(block_device: Dict, all_disks: List, all_partitions: List def scan_partitions() -> Tuple[bool, List, List, List, str]: """ 扫描系统中的所有磁盘和分区。 + 支持标准分区、LVM、LUKS、btrfs 等。 """ log_step("扫描系统分区", "使用 lsblk 获取磁盘和分区信息") + # 使用基础列名以确保兼容性(旧版本 lsblk 可能不支持 FSVER, ROTA, MODEL, PKNAME) success, stdout, stderr = run_command( ["sudo", "lsblk", "-J", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT,UUID,PARTLABEL,LABEL,TYPE,PARTTYPE"], "扫描分区" @@ -203,7 +513,7 @@ def scan_partitions() -> Tuple[bool, List, List, List, str]: log_info(f"扫描完成: 发现 {len(all_disks)} 个磁盘, {len(all_partitions)} 个分区, {len(all_efi_partitions)} 个EFI分区") for d in all_disks: - log_debug(f" 磁盘: {d['name']}") + log_debug(f" 磁盘: {d['name']} ({d.get('model', '')})") for p in all_partitions: log_debug(f" 分区: {p['name']} ({p['fstype']})") for e in all_efi_partitions: @@ -219,6 +529,74 @@ def scan_partitions() -> Tuple[bool, List, List, List, str]: return False, [], [], [], f"处理分区数据时发生未知错误: {e}" +def detect_filesystem_type(partition: str) -> str: + """ + 检测指定分区的文件系统类型。 + 使用 blkid 获取准确的文件系统信息。 + """ + try: + success, stdout, _ = run_command( + ["blkid", "-s", "TYPE", "-o", "value", partition], + f"检测文件系统类型: {partition}", + timeout=10 + ) + if success: + fs_type = stdout.strip() + log_debug(f"{partition} 的文件系统类型: {fs_type}") + return fs_type + except Exception as e: + log_debug(f"检测文件系统类型失败: {e}") + + return "unknown" + + +def is_btrfs_subvolume(mount_point: str) -> bool: + """检查指定路径是否是 btrfs 子卷""" + try: + success, stdout, _ = run_command( + ["btrfs", "subvolume", "get-default", mount_point], + "检查 btrfs 子卷", + timeout=10 + ) + return success + except Exception: + return False + + +def get_btrfs_root_subvolume(partition: str) -> Optional[str]: + """ + 获取 btrfs 分区的根子卷名称。 + 常见的子卷命名: @, @root, root + """ + try: + success, stdout, _ = run_command( + ["btrfs", "subvolume", "list", partition], + f"获取 btrfs 子卷: {partition}", + timeout=10 + ) + if success: + subvolumes = [] + for line in stdout.strip().split('\n'): + match = re.search(r'path\s+(\S+)', line) + if match: + subvolumes.append(match.group(1)) + + # 优先查找常见的根子卷名称 + for candidate in ["@", "root", "@root", "ROOT", "@ROOT"]: + if candidate in subvolumes: + log_debug(f"找到 btrfs 根子卷: {candidate}") + return candidate + + # 如果都没找到,返回第一个 + if subvolumes: + log_debug(f"使用第一个 btrfs 子卷: {subvolumes[0]}") + return subvolumes[0] + except Exception as e: + log_debug(f"获取 btrfs 子卷失败: {e}") + + return None + + def _cleanup_partial_mount(mount_point: str, mounted_boot: bool, mounted_efi: bool, bind_paths_mounted: List[str]) -> None: """清理部分挂载的资源""" @@ -249,9 +627,11 @@ def _cleanup_partial_mount(mount_point: str, mounted_boot: bool, mounted_efi: bo def mount_target_system(root_partition: str, boot_partition: Optional[str] = None, - efi_partition: Optional[str] = None) -> Tuple[bool, str, str]: + efi_partition: Optional[str] = None, + btrfs_subvolume: Optional[str] = None) -> Tuple[bool, str, str]: """ 挂载目标系统的根分区、/boot分区和EFI分区到临时目录。 + 支持 btrfs 子卷挂载。 """ log_step("挂载目标系统", f"根分区: {root_partition}, /boot: {boot_partition or '无'}, EFI: {efi_partition or '无'}") @@ -281,18 +661,30 @@ def mount_target_system(root_partition: str, boot_partition: Optional[str] = Non # 1. 挂载根分区 log_info(f"[1/4] 挂载根分区 {root_partition} 到 {mount_point}") - success, _, stderr = run_command(["sudo", "mount", root_partition, mount_point], - f"挂载根分区 {root_partition}") + + # 检测文件系统类型 + fs_type = detect_filesystem_type(root_partition) + + # 构建挂载命令 + mount_cmd = ["sudo", "mount"] + + # btrfs 子卷处理 + if fs_type == "btrfs" and btrfs_subvolume: + mount_cmd.extend(["-o", f"subvol={btrfs_subvolume}"]) + log_info(f" 使用 btrfs 子卷: {btrfs_subvolume}") + + mount_cmd.extend([root_partition, mount_point]) + + success, _, stderr = run_command(mount_cmd, f"挂载根分区 {root_partition}") if not success: log_error(f"挂载根分区失败,清理中...") os.rmdir(mount_point) return False, "", f"挂载根分区失败: {stderr}" - # 检查挂载是否成功 if not os.path.ismount(mount_point): log_error(f"挂载点未激活: {mount_point}") return False, "", "挂载根分区失败: 挂载点未激活" - log_info(f"✓ 根分区挂载成功") + log_success(f"✓ 根分区挂载成功") # 检查关键目录 for check_dir in ["etc", "boot"]: @@ -317,7 +709,7 @@ def mount_target_system(root_partition: str, boot_partition: Optional[str] = Non _cleanup_partial_mount(mount_point, False, False, []) return False, "", f"挂载 /boot 分区失败: {stderr}" mounted_boot = True - log_info(f"✓ /boot 分区挂载成功") + log_success(f"✓ /boot 分区挂载成功") else: log_info(f"[2/4] 无独立的 /boot 分区,跳过") @@ -336,7 +728,7 @@ def mount_target_system(root_partition: str, boot_partition: Optional[str] = Non _cleanup_partial_mount(mount_point, mounted_boot, False, []) return False, "", f"挂载 EFI 分区失败: {stderr}" mounted_efi = True - log_info(f"✓ EFI 分区挂载成功") + log_success(f"✓ EFI 分区挂载成功") # 检查 EFI 分区内容 efi_path = os.path.join(mount_point, "boot/efi/EFI") @@ -367,7 +759,7 @@ def mount_target_system(root_partition: str, boot_partition: Optional[str] = Non return False, "", f"绑定 {path} 失败: {stderr}" bind_paths_mounted.append(target_path) - log_info(f"✓ 所有绑定完成") + log_success(f"✓ 所有绑定完成") log_info(f"挂载摘要: 根分区 ✓, /boot {'✓' if mounted_boot else '✗'}, EFI {'✓' if mounted_efi else '✗'}") return True, mount_point, "" @@ -376,6 +768,8 @@ def mount_target_system(root_partition: str, boot_partition: Optional[str] = Non def detect_distro_type(mount_point: str) -> str: """ 检测目标系统发行版类型。 + 支持更多发行版:Arch, Debian, Ubuntu, CentOS/RHEL/Rocky/Alma, + Fedora, openSUSE, Void, Gentoo, NixOS 等。 """ log_step("检测发行版类型", f"挂载点: {mount_point}") @@ -402,8 +796,26 @@ def detect_distro_type(mount_point: str) -> str: elif "fedora" in content: log_info(f"通过 /etc/issue 检测到: fedora") return "fedora" + elif "void" in content: + log_info(f"通过 /etc/issue 检测到: void") + return "void" + elif "gentoo" in content: + log_info(f"通过 /etc/issue 检测到: gentoo") + return "gentoo" except Exception as e: log_warning(f"读取 /etc/issue 失败: {e}") + + # 检查其他发行版特定文件 + if os.path.exists(os.path.join(mount_point, "etc/arch-release")): + log_info("通过 arch-release 检测到: arch") + return "arch" + if os.path.exists(os.path.join(mount_point, "etc/gentoo-release")): + log_info("通过 gentoo-release 检测到: gentoo") + return "gentoo" + if os.path.exists(os.path.join(mount_point, "etc/nixos/configuration.nix")): + log_info("通过 configuration.nix 检测到: nixos") + return "nixos" + return "unknown" try: @@ -419,40 +831,48 @@ def detect_distro_type(mount_point: str) -> str: log_info(f"ID={distro_id}, ID_LIKE={id_like}") - if distro_id == "ubuntu": - log_info(f"检测到发行版: ubuntu") - return "ubuntu" - elif distro_id == "debian": - log_info(f"检测到发行版: debian") - return "debian" - elif distro_id in ["arch", "manjaro", "endeavouros"]: - log_info(f"检测到发行版: arch (ID={distro_id})") - return "arch" - elif distro_id in ["centos", "rhel", "rocky", "almalinux"]: - log_info(f"检测到发行版: centos (ID={distro_id})") - return "centos" - elif distro_id == "fedora": - log_info(f"检测到发行版: fedora") - return "fedora" - elif distro_id in ["opensuse", "opensuse-leap", "opensuse-tumbleweed"]: - log_info(f"检测到发行版: opensuse (ID={distro_id})") - return "opensuse" + # 直接匹配 + distro_map = { + "ubuntu": "ubuntu", + "debian": "debian", + "arch": "arch", + "manjaro": "arch", + "endeavouros": "arch", + "garuda": "arch", + "cachyos": "arch", + "centos": "centos", + "rhel": "centos", + "rocky": "centos", + "almalinux": "centos", + "fedora": "fedora", + "opensuse": "opensuse", + "opensuse-leap": "opensuse", + "opensuse-tumbleweed": "opensuse", + "void": "void", + "gentoo": "gentoo", + "nixos": "nixos", + } - if "ubuntu" in id_like: - log_info(f"通过 ID_LIKE 推断: ubuntu") - return "ubuntu" - elif "debian" in id_like: - log_info(f"通过 ID_LIKE 推断: debian") - return "debian" - elif "arch" in id_like: - log_info(f"通过 ID_LIKE 推断: arch") - return "arch" - elif "rhel" in id_like or "centos" in id_like or "fedora" in id_like: - log_info(f"通过 ID_LIKE 推断: centos") - return "centos" - elif "suse" in id_like: - log_info(f"通过 ID_LIKE 推断: opensuse") - return "opensuse" + if distro_id in distro_map: + result = distro_map[distro_id] + log_info(f"检测到发行版: {result} (ID={distro_id})") + return result + + # 通过 ID_LIKE 推断 + like_map = { + "ubuntu": "ubuntu", + "debian": "debian", + "arch": "arch", + "rhel": "centos", + "centos": "centos", + "fedora": "fedora", + "suse": "opensuse", + } + + for key, value in like_map.items(): + if key in id_like: + log_info(f"通过 ID_LIKE 推断: {value}") + return value log_warning(f"无法识别的发行版,ID={distro_id}, ID_LIKE={id_like}") return "unknown" @@ -464,11 +884,12 @@ def detect_distro_type(mount_point: str) -> str: def check_chroot_environment(mount_point: str) -> Tuple[bool, str]: """ 检查 chroot 环境是否可用。 + 检测可用的 GRUB 命令及其版本。 """ log_step("检查 chroot 环境", f"挂载点: {mount_point}") # 检查关键命令是否存在 - critical_commands = ["grub-install", "grub-mkconfig", "update-grub"] + critical_commands = ["grub-install", "grub-mkconfig", "update-grub", "grub2-install", "grub2-mkconfig"] found_commands = [] for cmd in critical_commands: @@ -478,14 +899,15 @@ def check_chroot_environment(mount_point: str) -> Tuple[bool, str]: found_commands.append(cmd) log_info(f" ✓ 找到命令: {cmd}") else: - log_warning(f" ✗ 未找到命令: {cmd}") + log_debug(f" ✗ 未找到命令: {cmd}") if not found_commands: log_error(f"chroot 环境中未找到关键的 GRUB 命令") return False, "chroot 环境中缺少 GRUB 命令" - # 检查 grub-install 版本 - success, stdout, _ = run_command(["sudo", "chroot", mount_point, "grub-install", "--version"], + # 检查 grub-install 版本(优先使用 grub2-install 如果存在) + grub_cmd = "grub2-install" if "grub2-install" in found_commands else "grub-install" + success, stdout, _ = run_command(["sudo", "chroot", mount_point, grub_cmd, "--version"], "检查 grub-install 版本", timeout=10) if success: log_info(f"grub-install 版本: {stdout.strip()}") @@ -494,26 +916,134 @@ def check_chroot_environment(mount_point: str) -> Tuple[bool, str]: efi_dir = os.path.join(mount_point, "boot/efi") if os.path.ismount(efi_dir): log_info(f"✓ EFI 分区已挂载到 /boot/efi") - # 检查 EFI 目录权限 try: stat = os.stat(efi_dir) - log_info(f" EFI 目录权限: {oct(stat.st_mode)}") + log_debug(f" EFI 目录权限: {oct(stat.st_mode)}") except Exception as e: log_warning(f" 无法获取 EFI 目录权限: {e}") else: log_info(f"EFI 分区未挂载(可能是 BIOS 模式)") + # 检查 Secure Boot 相关工具 + sb_tools = ["mokutil", "sbsign", "sbverify"] + for tool in sb_tools: + success, _, _ = run_command(["sudo", "chroot", mount_point, "which", tool], + f"检查 Secure Boot 工具 {tool}", timeout=5) + if success: + log_info(f" ✓ 找到 Secure Boot 工具: {tool}") + return True, "" +def install_efi_fallback(mount_point: str, efi_target: str, efi_grub_file: str, + efi_boot_file: str, bootloader_id: str = "GRUB") -> bool: + """ + 安装 EFI fallback 引导文件。 + 将 GRUB EFI 文件复制到 /EFI/Boot/bootx64.efi,这是 UEFI 的通用回退路径。 + 参考 Calamares 的实现。 + """ + log_step("安装 EFI Fallback", f"目标文件: {efi_boot_file}") + + efi_firmware_dir = os.path.join(mount_point, "boot/efi/EFI") + + # 处理 VFAT 大小写问题(某些固件对大小写敏感) + # 检查 EFI 目录的实际大小写 + if os.path.exists(efi_firmware_dir): + try: + entries = os.listdir(os.path.join(mount_point, "boot/efi")) + for entry in entries: + if entry.upper() == "EFI": + efi_firmware_dir = os.path.join(mount_point, "boot/efi", entry) + break + except Exception as e: + log_debug(f"检查 EFI 目录大小写失败: {e}") + + # 创建 Boot 目录(处理大小写) + boot_dir = os.path.join(efi_firmware_dir, "Boot") + try: + if os.path.exists(efi_firmware_dir): + entries = os.listdir(efi_firmware_dir) + for entry in entries: + if entry.upper() == "BOOT": + boot_dir = os.path.join(efi_firmware_dir, entry) + break + except Exception: + pass + + if not os.path.exists(boot_dir): + try: + os.makedirs(boot_dir) + log_info(f"创建 Boot 目录: {boot_dir}") + except Exception as e: + log_error(f"创建 Boot 目录失败: {e}") + return False + + # 源文件路径 + source_paths = [ + os.path.join(efi_firmware_dir, bootloader_id, efi_grub_file), + os.path.join(efi_firmware_dir, bootloader_id.lower(), efi_grub_file.lower()), + ] + + source_file = None + for path in source_paths: + if os.path.exists(path): + source_file = path + break + + if not source_file: + log_warning(f"找不到源 EFI 文件,尝试查找任何 .efi 文件") + # 尝试在 bootloader_id 目录下找到任何 .efi 文件 + for subdir in [bootloader_id, bootloader_id.lower(), "grub", "GRUB"]: + grub_dir = os.path.join(efi_firmware_dir, subdir) + if os.path.exists(grub_dir): + try: + for f in os.listdir(grub_dir): + if f.endswith(".efi"): + source_file = os.path.join(grub_dir, f) + log_info(f"找到替代源文件: {source_file}") + break + except Exception: + pass + if source_file: + break + + if not source_file: + log_error(f"无法找到源 EFI 文件") + return False + + # 目标文件路径 + target_file = os.path.join(boot_dir, efi_boot_file) + + try: + shutil.copy2(source_file, target_file) + log_success(f"✓ EFI fallback 安装成功: {source_file} -> {target_file}") + return True + except Exception as e: + log_error(f"复制 EFI fallback 文件失败: {e}") + return False + + def chroot_and_repair_grub(mount_point: str, target_disk: str, - is_uefi: bool = False, distro_type: str = "unknown") -> Tuple[bool, str]: + is_uefi: bool = False, distro_type: str = "unknown", + install_hybrid: bool = False, + use_fallback: bool = True, + efi_bootloader_id: str = "GRUB") -> Tuple[bool, str]: """ Chroot到目标系统并执行GRUB修复命令。 + 支持多种安装模式、架构、EFI fallback、Secure Boot 等。 + + 参数: + mount_point: 挂载点路径 + target_disk: 目标磁盘(BIOS模式) + is_uefi: 是否 UEFI 模式 + distro_type: 发行版类型 + install_hybrid: 是否同时安装 BIOS 和 EFI 模式(混合启动) + use_fallback: 是否安装 EFI fallback 文件 + efi_bootloader_id: EFI 启动项名称 """ log_step("修复 GRUB", f"目标磁盘: {target_disk}, UEFI: {is_uefi}, 发行版: {distro_type}") - if not validate_device_path(target_disk): + if not is_uefi and not validate_device_path(target_disk): log_error(f"无效的目标磁盘路径: {target_disk}") return False, f"无效的目标磁盘路径: {target_disk}" @@ -523,12 +1053,27 @@ def chroot_and_repair_grub(mount_point: str, target_disk: str, return False, err chroot_cmd_prefix = ["sudo", "chroot", mount_point] - - # 1. 安装GRUB - log_info(f"[1/2] 安装 GRUB 到 {target_disk}") - if is_uefi: - log_info(f"UEFI 模式安装...") + # 检测 Live 环境 + is_live_env = is_live_environment() + if is_live_env: + log_info("检测到 Live 环境(无法访问 EFI 变量)") + + # 获取 EFI 参数(如果需要) + efi_params = None + if is_uefi or install_hybrid: + efi_params = get_grub_efi_parameters() + if not efi_params: + return False, "无法确定 EFI 架构参数" + efi_target, efi_grub_file, efi_boot_file = efi_params + log_info(f"EFI 参数: target={efi_target}, grub={efi_grub_file}, boot={efi_boot_file}") + + grub_install_success = False + install_errors = [] + + # ===== UEFI 安装 ===== + if is_uefi or install_hybrid: + log_step("安装 UEFI GRUB") # 检查 EFI 分区是否正确挂载 efi_check_path = os.path.join(mount_point, "boot/efi/EFI") @@ -537,147 +1082,212 @@ def chroot_and_repair_grub(mount_point: str, target_disk: str, try: contents = os.listdir(efi_check_path) log_info(f" 当前 EFI 目录内容: {contents}") - - # 检查是否已有其他启动项 - existing_entries = [d for d in contents if d not in ['BOOT', 'boot']] - if existing_entries: - log_info(f" 发现已有启动项: {existing_entries}") - log_info(f" 建议:在BIOS中选择这些启动项之一,或继续使用新安装的GRUB") except Exception as e: log_warning(f" 无法列出 EFI 目录: {e}") else: log_warning(f"✗ EFI 目录不存在: {efi_check_path}") - # 检测是否在Live环境中(通过检查/sys/firmware/efi/efivars是否存在且可访问) - is_live_env = not os.path.exists("/sys/firmware/efi/efivars") or \ - not os.listdir("/sys/firmware/efi/efivars") + grub_install_cmd = chroot_cmd_prefix + [ + "grub-install" if distro_type != "centos" else "grub2-install", + f"--target={efi_target}", + "--efi-directory=/boot/efi", + f"--bootloader-id={efi_bootloader_id}", + "--force" + ] + # Live 环境下优先使用 --removable if is_live_env: - log_info(f"检测到 Live 环境(无法访问 EFI 变量),将使用 --removable 模式安装") + log_info("Live 环境: 优先使用 --removable 模式") + removable_cmd = chroot_cmd_prefix + [ + "grub-install" if distro_type != "centos" else "grub2-install", + f"--target={efi_target}", + "--efi-directory=/boot/efi", + "--removable", + "--force" + ] + success, stdout, stderr = run_command( + removable_cmd, + "安装 UEFI GRUB (removable 模式)", + timeout=60 + ) + if success: + grub_install_success = True + log_success("✓ removable 模式安装成功") + # 同时安装标准模式作为备选 + log_info("尝试安装标准模式作为备选...") + run_command(grub_install_cmd + ["--no-nvram"], "安装标准模式 (no-nvram)", timeout=60) + else: + log_warning(f"removable 模式失败,尝试标准模式") + install_errors.append(f"removable 模式: {stderr}") - # 第一次尝试:标准安装(带NVRAM注册) - log_info(f"尝试 1/3: 标准 UEFI 安装(带 NVRAM)...") - success, stdout, stderr = run_command( - chroot_cmd_prefix + ["grub-install", "--target=x86_64-efi", - "--efi-directory=/boot/efi", "--bootloader-id=GRUB", - "--verbose"], - "安装UEFI GRUB(带NVRAM)", - timeout=60 - ) - - if not success: - log_warning(f"标准安装失败") - log_debug(f"错误详情: {stderr}") + if not grub_install_success: + # 第一次尝试: 标准安装 + log_info("尝试 1/3: 标准 UEFI 安装...") + success, stdout, stderr = run_command( + grub_install_cmd, + "安装 UEFI GRUB(标准模式)", + timeout=60 + ) - # Live环境或NVRAM失败时,优先使用 --removable(创建 /EFI/Boot/bootx64.efi) - if "EFI variables are not supported" in stderr or "efibootmgr" in stderr or is_live_env: - log_info(f"尝试 2/3: Live环境模式(--removable,安装到 /EFI/Boot/bootx64.efi)...") + if success: + grub_install_success = True + log_success("✓ 标准安装成功") + else: + install_errors.append(f"标准模式: {stderr}") + log_warning(f"标准安装失败: {stderr}") + + # 第二次尝试: removable 模式 + log_info("尝试 2/3: Live环境/可移动模式(--removable)...") + removable_cmd = chroot_cmd_prefix + [ + "grub-install" if distro_type != "centos" else "grub2-install", + f"--target={efi_target}", + "--efi-directory=/boot/efi", + "--removable", + "--force" + ] success, stdout, stderr = run_command( - chroot_cmd_prefix + ["grub-install", "--target=x86_64-efi", - "--efi-directory=/boot/efi", - "--removable", "--verbose"], - "安装UEFI GRUB(可移动模式)", + removable_cmd, + "安装 UEFI GRUB(可移动模式)", timeout=60 ) if success: - log_info(f"✓ 可移动模式安装成功!") - log_info(f" GRUB 已安装到 /EFI/Boot/bootx64.efi") - log_info(f" 这是UEFI通用回退路径,应该在大多数BIOS中自动识别") - - # 如果 removable 也失败,尝试 --no-nvram(标准路径但不注册NVRAM) - if not success: - log_info(f"尝试 3/3: 使用 --no-nvram 选项(标准路径但不注册启动项)...") - success, stdout, stderr = run_command( - chroot_cmd_prefix + ["grub-install", "--target=x86_64-efi", - "--efi-directory=/boot/efi", "--bootloader-id=GRUB", - "--no-nvram", "--verbose"], - "安装UEFI GRUB(不带NVRAM)", - timeout=60 - ) + grub_install_success = True + log_success("✓ 可移动模式安装成功") + log_info(f" GRUB 已安装到 /EFI/Boot/{efi_boot_file}") + else: + install_errors.append(f"removable 模式: {stderr}") + + # 第三次尝试: no-nvram + log_info("尝试 3/3: 使用 --no-nvram 选项...") + success, stdout, stderr = run_command( + grub_install_cmd + ["--no-nvram"], + "安装 UEFI GRUB(不带NVRAM)", + timeout=60 + ) + + if success: + grub_install_success = True + log_success("✓ no-nvram 模式安装成功") + else: + install_errors.append(f"no-nvram 模式: {stderr}") + + # 安装 EFI fallback(如果启用) + if grub_install_success and use_fallback: + log_info("安装 EFI fallback 文件...") + install_efi_fallback(mount_point, efi_target, efi_grub_file, efi_boot_file, efi_bootloader_id) + + # ===== BIOS 安装 ===== + if not is_uefi or install_hybrid: + log_step("安装 BIOS GRUB") + + bios_cmd = chroot_cmd_prefix + [ + "grub-install" if distro_type != "centos" else "grub2-install", + "--target=i386-pc", + "--recheck", + "--force", + target_disk + ] - # 检查安装结果 - if success: - log_info(f"✓ GRUB EFI 文件安装成功") - # 检查生成的文件 - efi_paths_to_check = [ - os.path.join(mount_point, "boot/efi/EFI/GRUB"), - os.path.join(mount_point, "boot/efi/EFI/Boot"), - os.path.join(mount_point, "boot/efi/efi/GRUB"), # 某些系统使用小写 - os.path.join(mount_point, "boot/efi/efi/boot"), - ] - - found_efi_file = False - for path in efi_paths_to_check: - if os.path.exists(path): - log_info(f" 检查目录: {path}") - try: - files = os.listdir(path) - for f in files: - if f.endswith('.efi'): - full = os.path.join(path, f) - size = os.path.getsize(full) - log_info(f" ✓ EFI文件: {f} ({size} bytes)") - found_efi_file = True - except Exception as e: - log_warning(f" 无法列出: {e}") - - if not found_efi_file: - log_warning(f" 未找到任何 .efi 文件,安装可能有问题") - else: - log_info(f"BIOS 模式安装...") success, stdout, stderr = run_command( - chroot_cmd_prefix + ["grub-install", "--verbose", target_disk], - "安装BIOS GRUB", + bios_cmd, + f"安装 BIOS GRUB 到 {target_disk}", timeout=60 ) + + if success: + log_success(f"✓ BIOS GRUB 安装成功到 {target_disk}") + if not is_uefi: + grub_install_success = True + else: + log_error(f"BIOS GRUB 安装失败: {stderr}") + if not is_uefi: + install_errors.append(f"BIOS 模式: {stderr}") - if not success: - log_error(f"GRUB 安装失败") - return False, f"GRUB安装失败: {stderr}" + # 检查安装结果 + if not grub_install_success: + error_summary = "\n".join(install_errors) + log_error(f"所有 GRUB 安装尝试均失败:\n{error_summary}") + return False, f"GRUB 安装失败:\n{error_summary}" - log_info(f"✓ GRUB 安装成功") + log_success("✓ GRUB 安装阶段完成") + + # 检查生成的 EFI 文件 + if is_uefi or install_hybrid: + efi_paths_to_check = [ + os.path.join(mount_point, "boot/efi/EFI", efi_bootloader_id), + os.path.join(mount_point, "boot/efi/EFI", efi_bootloader_id.lower()), + os.path.join(mount_point, "boot/efi/EFI/Boot"), + os.path.join(mount_point, "boot/efi/efi", efi_bootloader_id.lower()), + ] + + found_efi_file = False + for path in efi_paths_to_check: + if os.path.exists(path): + log_info(f" 检查目录: {path}") + try: + files = os.listdir(path) + for f in files: + if f.endswith('.efi'): + full = os.path.join(path, f) + size = os.path.getsize(full) + log_info(f" ✓ EFI文件: {f} ({size} bytes)") + found_efi_file = True + except Exception as e: + log_warning(f" 无法列出: {e}") + + if not found_efi_file: + log_warning(f" 未找到任何 .efi 文件,安装可能有问题") - # 2. 更新GRUB配置 - log_info(f"[2/2] 更新 GRUB 配置文件") + # ===== 更新 GRUB 配置 ===== + log_step("更新 GRUB 配置文件") + # 确定正确的命令和路径 grub_update_cmd = [] config_path = "" - if distro_type in ["debian", "ubuntu"]: - grub_update_cmd = ["update-grub"] - config_path = "/boot/grub/grub.cfg" - elif distro_type == "arch": - grub_update_cmd = ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"] - config_path = "/boot/grub/grub.cfg" - elif distro_type == "centos": - grub2_path = os.path.join(mount_point, "boot/grub2/grub.cfg") - if os.path.exists(os.path.dirname(grub2_path)): - grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] - config_path = "/boot/grub2/grub.cfg" - else: - grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub/grub.cfg"] - config_path = "/boot/grub/grub.cfg" + # 根据发行版确定命令 + grub_install_bin = "grub-install" + grub_mkconfig_bin = "grub-mkconfig" + + if distro_type == "centos": + grub_install_bin = "grub2-install" + grub_mkconfig_bin = "grub2-mkconfig" elif distro_type == "fedora": - grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] - config_path = "/boot/grub2/grub.cfg" + grub_mkconfig_bin = "grub2-mkconfig" elif distro_type == "opensuse": - grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] - config_path = "/boot/grub2/grub.cfg" - else: - for cfg_path in ["/boot/grub/grub.cfg", "/boot/grub2/grub.cfg"]: - full_path = os.path.join(mount_point, cfg_path.lstrip('/')) - if os.path.exists(os.path.dirname(full_path)): - grub_update_cmd = ["grub-mkconfig", "-o", cfg_path] - config_path = cfg_path - break + grub_mkconfig_bin = "grub2-mkconfig" + + # 检测配置文件路径 + possible_config_paths = [ + "/boot/grub/grub.cfg", + "/boot/grub2/grub.cfg", + ] + + for cfg_path in possible_config_paths: + full_path = os.path.join(mount_point, cfg_path.lstrip('/')) + if os.path.exists(os.path.dirname(full_path)): + config_path = cfg_path + break + + if not config_path: + config_path = "/boot/grub/grub.cfg" + + # 确定更新命令 + if distro_type in ["debian", "ubuntu"]: + # Debian/Ubuntu 使用 update-grub 包装脚本 + success, _, _ = run_command(chroot_cmd_prefix + ["which", "update-grub"], "检查 update-grub", timeout=5) + if success: + grub_update_cmd = ["update-grub"] else: - grub_update_cmd = ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"] - config_path = "/boot/grub/grub.cfg" + grub_update_cmd = [grub_mkconfig_bin, "-o", config_path] + else: + grub_update_cmd = [grub_mkconfig_bin, "-o", config_path] log_info(f"使用命令: {' '.join(grub_update_cmd)}") log_info(f"配置文件路径: {config_path}") + # 执行配置更新 success, stdout, stderr = run_command( chroot_cmd_prefix + grub_update_cmd, "更新GRUB配置文件", @@ -688,14 +1298,13 @@ def chroot_and_repair_grub(mount_point: str, target_disk: str, log_error(f"GRUB 配置文件更新失败: {stderr}") return False, f"GRUB配置文件更新失败: {stderr}" - log_info(f"✓ GRUB 配置文件更新成功") + log_success("✓ GRUB 配置文件更新成功") # 检查生成的配置文件 full_config_path = os.path.join(mount_point, config_path.lstrip('/')) if os.path.exists(full_config_path): size = os.path.getsize(full_config_path) log_info(f" 配置文件大小: {size} bytes") - # 检查配置文件中是否包含菜单项 try: with open(full_config_path, 'r') as f: content = f.read() @@ -706,40 +1315,36 @@ def chroot_and_repair_grub(mount_point: str, target_disk: str, else: log_warning(f" 配置文件未找到: {full_config_path}") - # UEFI 模式下输出重要提示 - if is_uefi: + # ===== 输出启动提示 ===== + if is_uefi or install_hybrid: log_step("UEFI 修复完成提示") - log_info(f"GRUB EFI 文件已安装到 EFI 分区") + log_info("GRUB EFI 文件已安装到 EFI 分区") # 检测安装模式并给出相应提示 - efi_boot_path = os.path.join(mount_point, "boot/efi/EFI/Boot/bootx64.efi") - efi_grub_path = os.path.join(mount_point, "boot/efi/EFI/GRUB/grubx64.efi") + efi_boot_path = os.path.join(mount_point, f"boot/efi/EFI/Boot/{efi_boot_file if efi_params else 'bootx64.efi'}") + efi_grub_path = os.path.join(mount_point, f"boot/efi/EFI/{efi_bootloader_id}/{efi_grub_file if efi_params else 'grubx64.efi'}") if os.path.exists(efi_boot_path): - log_info(f"") - log_info(f"【重要】使用可移动模式安装 (/EFI/Boot/bootx64.efi)") - log_info(f"启动方法:") - log_info(f" 1. 在BIOS中选择 'UEFI Hard Drive' 或 'UEFI OS' 启动") - log_info(f" 2. 如果有多块硬盘,选择正确的硬盘 (通常是第一块)") - log_info(f" 3. 确保启动模式为 UEFI(不是 Legacy/CSM)") + log_info("") + log_info("【重要】使用可移动模式安装 (EFI Fallback)") + log_info("启动方法:") + log_info(" 1. 在BIOS中选择 'UEFI Hard Drive' 或 'UEFI OS' 启动") + log_info(" 2. 如果有多块硬盘,选择正确的硬盘") + log_info(" 3. 确保启动模式为 UEFI(不是 Legacy/CSM)") elif os.path.exists(efi_grub_path): - log_info(f"") - log_info(f"【重要】使用标准模式安装 (/EFI/GRUB/grubx64.efi)") - log_info(f"启动方法:") - log_info(f" 1. 在BIOS启动菜单中选择 'GRUB' 或 'Linux' 启动项") - log_info(f" 2. 如果没有该选项,需要手动添加启动项") - log_info(f" 路径: \\EFI\\GRUB\\grubx64.efi") + log_info("") + log_info(f"【重要】使用标准模式安装 (/{efi_bootloader_id}/{efi_grub_file if efi_params else 'grubx64.efi'})") + log_info("启动方法:") + log_info(f" 1. 在BIOS启动菜单中选择 '{efi_bootloader_id}' 或 'Linux' 启动项") + log_info(" 2. 如果没有该选项,需要手动添加启动项") + log_info(f" 路径: \\EFI\\{efi_bootloader_id}\\{efi_grub_file if efi_params else 'grubx64.efi'}") - log_info(f"") - log_info(f"故障排除:") - log_info(f" • 如果仍无法启动,请检查:") - log_info(f" - BIOS 是否设置为 UEFI 启动模式(非 Legacy/CSM)") - log_info(f" - 安全启动 (Secure Boot) 是否已禁用") - log_info(f" - EFI 分区格式是否为 FAT32") - log_info(f"") - log_info(f" • 如果BIOS中有旧启动项(如 BBT-TMS-OS),请尝试:") - log_info(f" - 选择该启动项可能可以启动(如果它指向正确的系统)") - log_info(f" - 或删除旧启动项,让BIOS重新检测") + log_info("") + log_info("故障排除:") + log_info(" • 如果仍无法启动,请检查:") + log_info(" - BIOS 是否设置为 UEFI 启动模式(非 Legacy/CSM)") + log_info(" - 安全启动 (Secure Boot) 是否已禁用") + log_info(" - EFI 分区格式是否为 FAT32") return True, "" @@ -747,23 +1352,31 @@ def chroot_and_repair_grub(mount_point: str, target_disk: str, def unmount_target_system(mount_point: str) -> Tuple[bool, str]: """ 卸载所有挂载的分区。 + 确保按正确顺序卸载,避免资源忙错误。 """ log_step("卸载目标系统", f"挂载点: {mount_point}") success = True error_msg = "" + # 按逆序卸载绑定挂载 bind_paths = ["/run", "/sys", "/proc", "/dev/pts", "/dev"] for path in bind_paths: target_path = os.path.join(mount_point, path.lstrip('/')) if os.path.ismount(target_path): - s, _, stderr = run_command(["sudo", "umount", target_path], f"卸载绑定 {target_path}") + # 尝试多次卸载(有时需要重试) + for attempt in range(3): + s, _, stderr = run_command(["sudo", "umount", target_path], f"卸载绑定 {target_path}") + if s: + break + time.sleep(0.5) if not s: success = False error_msg += f"卸载绑定 {target_path} 失败: {stderr}\n" else: log_debug(f"未挂载,跳过: {target_path}") + # 卸载 EFI 分区 efi_mount_point = os.path.join(mount_point, "boot/efi") if os.path.ismount(efi_mount_point): s, _, stderr = run_command(["sudo", "umount", efi_mount_point], f"卸载 EFI 分区") @@ -773,6 +1386,7 @@ def unmount_target_system(mount_point: str) -> Tuple[bool, str]: else: log_debug(f"EFI 分区未挂载,跳过") + # 卸载 /boot 分区 boot_mount_point = os.path.join(mount_point, "boot") if os.path.ismount(boot_mount_point): s, _, stderr = run_command(["sudo", "umount", boot_mount_point], f"卸载 /boot 分区") @@ -782,6 +1396,7 @@ def unmount_target_system(mount_point: str) -> Tuple[bool, str]: else: log_debug(f"/boot 分区未挂载,跳过") + # 卸载根分区 if os.path.ismount(mount_point): s, _, stderr = run_command(["sudo", "umount", mount_point], f"卸载根分区") if not s: @@ -790,25 +1405,141 @@ def unmount_target_system(mount_point: str) -> Tuple[bool, str]: else: log_debug(f"根分区未挂载,跳过") + # 清理临时目录 if os.path.exists(mount_point) and not os.path.ismount(mount_point): try: shutil.rmtree(mount_point) - log_info(f"✓ 清理临时目录: {mount_point}") + log_success(f"✓ 清理临时目录: {mount_point}") except OSError as e: log_warning(f"无法删除临时目录 {mount_point}: {e}") error_msg += f"无法删除临时目录: {e}\n" if success: - log_info(f"✓ 所有分区已卸载") + log_success("✓ 所有分区已卸载") else: - log_warning(f"部分分区卸载失败") + log_warning("部分分区卸载失败") return success, error_msg +# ===== 高级功能函数 ===== + +def detect_existing_grub_entries(mount_point: str) -> List[Dict]: + """ + 检测 EFI 分区中已有的 GRUB 启动项。 + 返回发现的 EFI 启动项列表。 + """ + entries = [] + efi_path = os.path.join(mount_point, "boot/efi/EFI") + + if not os.path.exists(efi_path): + return entries + + try: + for item in os.listdir(efi_path): + item_path = os.path.join(efi_path, item) + if os.path.isdir(item_path): + efi_files = [f for f in os.listdir(item_path) if f.endswith('.efi')] + if efi_files: + entries.append({ + "name": item, + "path": item_path, + "files": efi_files + }) + except Exception as e: + log_debug(f"检测现有 GRUB 条目失败: {e}") + + return entries + + +def install_refind(mount_point: str) -> Tuple[bool, str]: + """ + 安装 rEFInd 作为备用引导管理器。 + 需要目标系统中已安装 rEFInd 包。 + """ + log_step("安装 rEFInd") + + chroot_cmd_prefix = ["sudo", "chroot", mount_point] + + # 检查 refind-install 是否存在 + success, _, _ = run_command( + chroot_cmd_prefix + ["which", "refind-install"], + "检查 refind-install", + timeout=10 + ) + + if not success: + return False, "未找到 refind-install,请确保已安装 rEFInd 包" + + # 运行 refind-install + success, stdout, stderr = run_command( + chroot_cmd_prefix + ["refind-install"], + "安装 rEFInd", + timeout=60 + ) + + if success: + log_success("✓ rEFInd 安装成功") + return True, "" + else: + log_error(f"rEFInd 安装失败: {stderr}") + return False, f"rEFInd 安装失败: {stderr}" + + +def update_firmware_boot_order(mount_point: str, efi_bootloader_id: str = "GRUB") -> bool: + """ + 使用 efibootmgr 更新固件启动顺序。 + 注意:在 Live 环境中可能无法工作。 + """ + if is_live_environment(): + log_warning("Live 环境无法更新固件启动顺序") + return False + + log_step("更新固件启动顺序") + + # 查找 EFI 分区 + efi_partition = None + for line in open("/proc/mounts"): + if mount_point in line and "/boot/efi" in line: + parts = line.split() + efi_partition = parts[0] + break + + if not efi_partition: + log_warning("无法找到 EFI 分区") + return False + + # 使用 efibootmgr 创建启动项 + success, _, stderr = run_command( + ["efibootmgr", "-c", "-L", efi_bootloader_id, + "-l", f"\\EFI\\{efi_bootloader_id}\\grubx64.efi"], + "创建 EFI 启动项", + timeout=10 + ) + + if success: + log_success("✓ 固件启动顺序更新成功") + return True + else: + log_warning(f"更新固件启动顺序失败: {stderr}") + return False + + if __name__ == "__main__": print("--- 运行后端测试 ---") + print("\n--- 系统信息检测 ---") + print(f"EFI 位数: {get_efi_word_size()}") + print(f"CPU 架构: {platform.machine()}") + print(f"Live 环境: {is_live_environment()}") + + sb_supported, sb_enabled = check_secure_boot_status() + print(f"Secure Boot 支持: {sb_supported}, 启用: {sb_enabled}") + + efi_params = get_grub_efi_parameters() + if efi_params: + print(f"GRUB EFI 参数: {efi_params}") + print("\n--- 扫描分区测试 ---") success, disks, partitions, efi_partitions, err = scan_partitions() if success: diff --git a/frontend.py b/frontend.py index d643950..cb8653d 100644 --- a/frontend.py +++ b/frontend.py @@ -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() + + # 设置后端日志回调 + backend.set_log_callback(self._backend_log_callback) + + 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): - # Frame for partition selection - self.partition_frame = ttk.LabelFrame(self.master, text="1. 选择目标系统分区", padding="10") + """创建 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( + "", + 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("", 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) # 让 Combobox 填充宽度 + self.partition_frame.columnconfigure(1, weight=1) - ttk.Label(self.partition_frame, text="根分区 (/):").grid(row=0, column=0, padx=5, pady=2, sticky="w") + # 根分区 + 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("<>", self.on_root_partition_selected) + + # 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)) - ttk.Label(self.partition_frame, text="独立 /boot 分区 (可选):").grid(row=1, column=0, padx=5, pady=2, sticky="w") + # /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") + + # 混合启动模式 + 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") + + # 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") - # 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.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) - # Progress and Log - self.log_frame = ttk.LabelFrame(self.master, text="修复日志", padding="10") + # ===== 日志区域 ===== + 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) + + # 配置日志标签颜色 + 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 log_message(self, message, level="info"): - """ - 线程安全的日志记录函数。 - """ + 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.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") - - def on_root_partition_selected(self, event): + self.log_message(f"扫描完成: 发现 {len(disks)} 个磁盘, {len(partitions)} 个分区", "success") + self.rescan_button.config(state="normal") + + 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) - self.log_message(f"自动选择目标磁盘: {self.selected_target_disk_info['name']}", "info") + # 根据分区路径推断磁盘 + 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() - - 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 + self.log_message("=" * 60, "step") + self.log_message("GRUB 引导修复过程开始", "step") + self.log_message("=" * 60, "step") - - # 启动新线程进行修复,避免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'] - - 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") + 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 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") + # 3. Chroot 并修复 GRUB + 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.log_message(f"GRUB 修复失败: {repair_err}", "error") + self.master.after(0, lambda: messagebox.showerror("修复失败", f"GRUB 修复失败:\n{repair_err}")) else: - self.log_message("GRUB修复命令执行成功!", "success") - + 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() - diff --git a/tip/main.py b/tip/main.py new file mode 100644 index 0000000..d2df155 --- /dev/null +++ b/tip/main.py @@ -0,0 +1,967 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Aurélien Gâteau +# SPDX-FileCopyrightText: 2014 Anke Boersma +# SPDX-FileCopyrightText: 2014 Daniel Hillenbrand +# SPDX-FileCopyrightText: 2014 Benjamin Vaudour +# SPDX-FileCopyrightText: 2014-2019 Kevin Kofler +# SPDX-FileCopyrightText: 2015-2018 Philip Mueller +# SPDX-FileCopyrightText: 2016-2017 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2017 Gabriel Craciunescu +# SPDX-FileCopyrightText: 2017 Ben Green +# SPDX-FileCopyrightText: 2021 Neal Gompa +# 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 ${} 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 ${} 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, ""))) + 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
{!s}
returned error " + "code {!s}.") + .format(e.cmd, e.returncode)) + + return None