diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2f59a96 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# BootRepairTool - 项目说明 + +## 项目概述 + +**BootRepairTool** 是一个用于修复 Linux 系统 GRUB 引导的图形化工具。适用于 Live USB/CD 环境,帮助用户修复损坏的 GRUB 引导加载器。 + +## 项目结构 + +``` +BootRepairTool/ +├── backend.py # 后端逻辑 - 分区扫描、挂载、GRUB修复 +├── frontend.py # 前端界面 - tkinter GUI +├── README.md # 项目简介 +└── AGENTS.md # 本文件 +``` + +## 技术栈 + +- **语言**: Python 3 +- **GUI 框架**: tkinter +- **运行环境**: Linux Live USB/CD (需要 root/sudo 权限) + +## 核心功能 + +### 1. 分区扫描 (`backend.py:scan_partitions`) +- 使用 `lsblk -J` 获取磁盘和分区信息 +- 识别 EFI 系统分区 (ESP) +- 过滤 Live 系统自身的分区 + +### 2. 系统挂载 (`backend.py:mount_target_system`) +- 挂载根分区 (/) +- 挂载独立 /boot 分区(可选) +- 挂载 EFI 分区(UEFI 模式) +- 绑定伪文件系统 (/dev, /proc, /sys, /run) + +### 3. 发行版检测 (`backend.py:detect_distro_type`) +- 支持: Arch, CentOS/RHEL, Debian, Ubuntu +- 通过读取 `/etc/os-release` 识别 + +### 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`) +- 逆序卸载绑定的文件系统 +- 卸载 EFI/boot/根分区 +- 清理临时挂载点 + +## 前端界面 (`frontend.py`) + +### 主要组件 + +1. **分区选择区域** + - 根分区 (/) - 必选 + - 独立 /boot 分区 - 可选 + - EFI 系统分区 (ESP) - UEFI 模式下推荐 + +2. **目标磁盘选择** + - 物理磁盘选择 (如 /dev/sda) + - UEFI 模式复选框(自动检测 Live 环境) + +3. **日志窗口** + - 彩色日志输出(信息/警告/错误/成功) + - 自动滚动 + +### 工作流程 + +``` +扫描分区 → 用户选择分区 → 点击修复 → 挂载系统 → +检测发行版 → chroot修复GRUB → 卸载分区 → 完成 +``` + +### 线程安全 + +修复过程在独立线程中运行,避免 UI 卡死。使用 `master.after()` 进行线程安全的日志更新。 + +## 代码规范 + +### 后端函数返回值 +所有后端函数统一返回 `(success: bool, ..., error_message: str)` 格式: +```python +# 示例 +success, stdout, stderr = run_command([...]) +success, disks, partitions, efi_parts, err = scan_partitions() +``` + +### 错误处理 +- 命令执行失败时自动清理已挂载的分区 +- 日志记录使用 `[Backend]` 前缀 +- GUI 使用弹窗显示关键错误 + +## 使用限制 + +1. **需要 root 权限**: 所有磁盘操作需要 `sudo` +2. **Live 环境**: 设计为在 Live USB/CD 中运行 +3. **单线程修复**: 同时只能进行一个修复任务 +4. **UEFI 自动检测**: 根据 Live 环境自动设置 UEFI 模式 + +## 测试 + +直接运行 `backend.py` 可进行基础测试(仅扫描分区): +```bash +sudo python backend.py +``` + +完整修复测试需要取消注释 `__main__` 块中的测试代码并设置实际分区。 + +## 潜在改进点 + +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` diff --git a/__pycache__/backend.cpython-36.pyc b/__pycache__/backend.cpython-36.pyc new file mode 100644 index 0000000..1db805e Binary files /dev/null and b/__pycache__/backend.cpython-36.pyc differ diff --git a/__pycache__/backend.cpython-39.pyc b/__pycache__/backend.cpython-39.pyc new file mode 100644 index 0000000..edcd0e5 Binary files /dev/null and b/__pycache__/backend.cpython-39.pyc differ diff --git a/__pycache__/frontend.cpython-36.pyc b/__pycache__/frontend.cpython-36.pyc new file mode 100644 index 0000000..79f542a Binary files /dev/null and b/__pycache__/frontend.cpython-36.pyc differ diff --git a/backend.py b/backend.py index 8dad89d..17dfc08 100644 --- a/backend.py +++ b/backend.py @@ -3,16 +3,51 @@ import subprocess import os import json -import time # 用于模拟耗时操作,实际可移除 +import time +import re +import shutil +from typing import Tuple, List, Dict, Optional -def run_command(command, description="执行命令", cwd=None, shell=False): +# 常量配置 +COMMAND_TIMEOUT = 300 # 命令执行超时时间(秒) +DEFAULT_GRUB_CONFIG_PATHS = [ + "/boot/grub/grub.cfg", + "/boot/grub2/grub.cfg", +] + + +def validate_device_path(path: str) -> bool: + """ + 验证设备路径格式是否合法(防止命令注入)。 + :param path: 设备路径(如 /dev/sda1, /dev/mapper/cl-root) + :return: 是否合法 + """ + if not path: + return False + # 允许的格式: + # - /dev/sda, /dev/sda1 + # - /dev/nvme0n1, /dev/nvme0n1p1 + # - /dev/mmcblk0, /dev/mmcblk0p1 + # - /dev/mapper/xxx (LVM逻辑卷) + # - /dev/dm-0 等 + patterns = [ + r'^/dev/[a-zA-Z0-9_-]+$', + r'^/dev/mapper/[a-zA-Z0-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]: """ 运行一个系统命令,并捕获其输出和错误。 :param command: 要执行的命令列表 (例如 ["sudo", "lsblk"]) :param description: 命令的描述,用于日志 :param cwd: 更改工作目录 - :param shell: 是否使用shell执行命令 (通常不推荐,除非命令需要shell特性) - :return: (True/False, stdout, stderr) 表示成功、标准输出、标准错误 + :param shell: 是否使用shell执行命令 + :param timeout: 命令超时时间(秒) + :return: (success, stdout, stderr) 表示成功、标准输出、标准错误 """ try: print(f"[Backend] {description}: {' '.join(command)}") @@ -20,9 +55,10 @@ def run_command(command, description="执行命令", cwd=None, shell=False): command, capture_output=True, text=True, - check=True, # 如果命令返回非零退出码,将抛出CalledProcessError + check=True, cwd=cwd, - shell=shell + shell=shell, + timeout=timeout ) print(f"[Backend] 命令成功: {description}") return True, result.stdout, result.stderr @@ -30,6 +66,9 @@ def run_command(command, description="执行命令", cwd=None, shell=False): print(f"[Backend] 命令失败: {description}") print(f"[Backend] 错误输出: {e.stderr}") return False, e.stdout, e.stderr + except subprocess.TimeoutExpired: + print(f"[Backend] 命令超时: {description}") + return False, "", f"命令执行超时(超过 {timeout} 秒)" except FileNotFoundError: print(f"[Backend] 命令未找到: {' '.join(command)}") return False, "", f"命令未找到: {command[0]}" @@ -37,16 +76,103 @@ def run_command(command, description="执行命令", cwd=None, shell=False): print(f"[Backend] 发生未知错误: {e}") return False, "", str(e) -def scan_partitions(): + +def _process_partition(block_device: Dict, all_disks: List, all_partitions: List, + all_efi_partitions: List) -> None: + """ + 处理单个块设备,递归处理子分区、LVM逻辑卷等。 + """ + dev_name = f"/dev/{block_device.get('name', '')}" + dev_type = block_device.get("type") + + if dev_type == "disk": + all_disks.append({"name": dev_name}) + # 递归处理子分区 + for child in block_device.get("children", []): + _process_partition(child, all_disks, all_partitions, all_efi_partitions) + + elif dev_type == "part": + # 过滤掉Live系统自身的分区 + mountpoint = block_device.get("mountpoint") + if mountpoint and (mountpoint == "/" or + mountpoint.startswith("/run/media") or + mountpoint.startswith("/cdrom") or + mountpoint.startswith("/live")): + # 继续处理子设备(如LVM),但自己不被标记为可用分区 + 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"), + "size": block_device.get("size", "unknown"), + "mountpoint": mountpoint, + "uuid": block_device.get("uuid"), + "partlabel": block_device.get("partlabel"), + "label": block_device.get("label"), + "parttype": (block_device.get("parttype") or "").upper() # EFI分区类型GUID + } + all_partitions.append(part_info) + + # 识别EFI系统分区 (FAT32文件系统, 且满足以下条件之一) + 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() + is_efi_type = part_info["parttype"] == "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" + + if is_vfat and (has_efi_label or has_efi_name or is_efi_type): + all_efi_partitions.append(part_info) + + # 递归处理子设备(如LVM逻辑卷) + for child in block_device.get("children", []): + _process_partition(child, all_disks, all_partitions, all_efi_partitions) + + elif dev_type in ["lvm", "dm"]: + # 处理LVM逻辑卷和Device Mapper设备 + mountpoint = block_device.get("mountpoint") + + # 过滤Live系统自身的挂载点 + if mountpoint and (mountpoint == "/" or + mountpoint.startswith("/run/media") or + mountpoint.startswith("/cdrom") or + mountpoint.startswith("/live")): + pass # 仍然添加到分区列表,但标记一下 + + # 尝试获取mapper路径(如 /dev/mapper/cl-root) + lv_name = block_device.get("name", "") + # 如果名称中包含'-',可能是mapper设备 + if "-" in lv_name and not lv_name.startswith("dm-"): + mapper_path = f"/dev/mapper/{lv_name}" + else: + mapper_path = dev_name + + part_info = { + "name": mapper_path, + "fstype": block_device.get("fstype", "unknown"), + "size": block_device.get("size", "unknown"), + "mountpoint": mountpoint, + "uuid": block_device.get("uuid"), + "partlabel": block_device.get("partlabel"), + "label": block_device.get("label"), + "parttype": (block_device.get("parttype") or "").upper(), + "is_lvm": True, + "dm_name": lv_name + } + all_partitions.append(part_info) + + # 递归处理可能的子设备 + for child in block_device.get("children", []): + _process_partition(child, all_disks, all_partitions, all_efi_partitions) + + +def scan_partitions() -> Tuple[bool, List, List, List, str]: """ 扫描系统中的所有磁盘和分区,并返回结构化的信息。 :return: (success, disks, partitions, efi_partitions, error_message) - disks: list of {"name": "/dev/sda"} - partitions: list of {"name": "/dev/sda1", "fstype": "ext4", "size": "100G", "mountpoint": "/"} - efi_partitions: list of {"name": "/dev/sda1", "fstype": "vfat", "size": "512M"} """ success, stdout, stderr = run_command( - ["sudo", "lsblk", "-J", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT,UUID,PARTLABEL,LABEL,TYPE"], + ["sudo", "lsblk", "-J", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT,UUID,PARTLABEL,LABEL,TYPE,PARTTYPE"], "扫描分区" ) @@ -60,31 +186,7 @@ def scan_partitions(): all_efi_partitions = [] for block_device in data.get("blockdevices", []): - dev_name = f"/dev/{block_device['name']}" - dev_type = block_device.get("type") - - if dev_type == "disk": - all_disks.append({"name": dev_name}) - elif dev_type == "part": - # 过滤掉Live系统自身的分区(基于常见的Live环境挂载点) - mountpoint = block_device.get("mountpoint") - if mountpoint and (mountpoint == "/" or mountpoint.startswith("/run/media") or mountpoint.startswith("/cdrom")): - continue - - part_info = { - "name": dev_name, - "fstype": block_device.get("fstype", "unknown"), - "size": block_device.get("size", "unknown"), - "mountpoint": mountpoint, - "uuid": block_device.get("uuid"), - "partlabel": block_device.get("partlabel"), - "label": block_device.get("label") - } - all_partitions.append(part_info) - - # 识别EFI系统分区 (FAT32文件系统, 且通常有特定标签或名称) - if part_info["fstype"] == "vfat" and (part_info["partlabel"] == "EFI System Partition" or part_info["label"] == "EFI"): - all_efi_partitions.append(part_info) + _process_partition(block_device, all_disks, all_partitions, all_efi_partitions) return True, all_disks, all_partitions, all_efi_partitions, "" @@ -93,21 +195,77 @@ def scan_partitions(): except Exception as e: return False, [], [], [], f"处理分区数据时发生未知错误: {e}" -def mount_target_system(root_partition, boot_partition=None, efi_partition=None): + +def _cleanup_partial_mount(mount_point: str, mounted_boot: bool, mounted_efi: bool, + bind_paths_mounted: List[str]) -> None: + """ + 清理部分挂载的资源(用于挂载失败时的回滚)。 + """ + print(f"[Backend] 清理部分挂载资源: {mount_point}") + + # 逆序卸载已绑定的路径 + for target_path in reversed(bind_paths_mounted): + if os.path.ismount(target_path): + run_command(["sudo", "umount", target_path], f"清理卸载绑定 {target_path}") + + # 卸载EFI分区 + if mounted_efi: + efi_mount_point = os.path.join(mount_point, "boot/efi") + if os.path.ismount(efi_mount_point): + run_command(["sudo", "umount", efi_mount_point], "清理卸载EFI分区") + + # 卸载/boot分区 + if mounted_boot: + boot_mount_point = os.path.join(mount_point, "boot") + if os.path.ismount(boot_mount_point): + run_command(["sudo", "umount", boot_mount_point], "清理卸载/boot分区") + + # 卸载根分区 + if os.path.ismount(mount_point): + run_command(["sudo", "umount", mount_point], "清理卸载根分区") + + # 删除临时目录 + if os.path.exists(mount_point) and not os.path.ismount(mount_point): + try: + os.rmdir(mount_point) + except OSError: + pass + + +def mount_target_system(root_partition: str, boot_partition: Optional[str] = None, + efi_partition: Optional[str] = None) -> Tuple[bool, str, str]: """ 挂载目标系统的根分区、/boot分区和EFI分区到临时目录。 - :param root_partition: 目标系统的根分区设备路径 (例如 /dev/sda1) + :param root_partition: 目标系统的根分区设备路径 :param boot_partition: 目标系统的独立 /boot 分区设备路径 (可选) :param efi_partition: 目标系统的EFI系统分区设备路径 (可选,仅UEFI) :return: (True/False, mount_point, error_message) """ - mount_point = "/mnt_grub_repair_" + str(int(time.time())) # 确保唯一性 - if not os.path.exists(mount_point): - os.makedirs(mount_point) + # 验证输入 + if not validate_device_path(root_partition): + return False, "", f"无效的根分区路径: {root_partition}" + if boot_partition and not validate_device_path(boot_partition): + return False, "", f"无效的/boot分区路径: {boot_partition}" + if efi_partition and not validate_device_path(efi_partition): + return False, "", f"无效的EFI分区路径: {efi_partition}" + + # 创建临时挂载点 + mount_point = "/mnt_grub_repair_" + str(int(time.time())) + try: + os.makedirs(mount_point, exist_ok=False) + except Exception as e: + return False, "", f"创建挂载点失败: {e}" + + # 跟踪已挂载的资源,用于失败时清理 + bind_paths_mounted = [] + mounted_boot = False + mounted_efi = False # 1. 挂载根分区 - success, _, stderr = run_command(["sudo", "mount", root_partition, mount_point], f"挂载根分区 {root_partition}") + success, _, stderr = run_command(["sudo", "mount", root_partition, mount_point], + f"挂载根分区 {root_partition}") if not success: + os.rmdir(mount_point) return False, "", f"挂载根分区失败: {stderr}" # 2. 如果有独立 /boot 分区 @@ -115,20 +273,24 @@ def mount_target_system(root_partition, boot_partition=None, efi_partition=None) boot_mount_point = os.path.join(mount_point, "boot") if not os.path.exists(boot_mount_point): os.makedirs(boot_mount_point) - success, _, stderr = run_command(["sudo", "mount", boot_partition, boot_mount_point], f"挂载 /boot 分区 {boot_partition}") + success, _, stderr = run_command(["sudo", "mount", boot_partition, boot_mount_point], + f"挂载 /boot 分区 {boot_partition}") if not success: - unmount_target_system(mount_point) # 挂载失败,尝试清理 + _cleanup_partial_mount(mount_point, False, False, []) return False, "", f"挂载 /boot 分区失败: {stderr}" + mounted_boot = True # 3. 如果有EFI分区 (UEFI系统) if efi_partition: efi_mount_point = os.path.join(mount_point, "boot/efi") if not os.path.exists(efi_mount_point): os.makedirs(efi_mount_point) - success, _, stderr = run_command(["sudo", "mount", efi_partition, efi_mount_point], f"挂载 EFI 分区 {efi_partition}") + success, _, stderr = run_command(["sudo", "mount", efi_partition, efi_mount_point], + f"挂载 EFI 分区 {efi_partition}") if not success: - unmount_target_system(mount_point) # 挂载失败,尝试清理 + _cleanup_partial_mount(mount_point, mounted_boot, False, []) return False, "", f"挂载 EFI 分区失败: {stderr}" + mounted_efi = True # 4. 绑定必要的伪文件系统 bind_paths = ["/dev", "/dev/pts", "/proc", "/sys", "/run"] @@ -136,63 +298,108 @@ def mount_target_system(root_partition, boot_partition=None, efi_partition=None) target_path = os.path.join(mount_point, path.lstrip('/')) if not os.path.exists(target_path): os.makedirs(target_path) - success, _, stderr = run_command(["sudo", "mount", "--bind", path, target_path], f"绑定 {path} 到 {target_path}") + success, _, stderr = run_command(["sudo", "mount", "--bind", path, target_path], + f"绑定 {path} 到 {target_path}") if not success: - unmount_target_system(mount_point) # 绑定失败,尝试清理 + _cleanup_partial_mount(mount_point, mounted_boot, mounted_efi, bind_paths_mounted) return False, "", f"绑定 {path} 失败: {stderr}" + bind_paths_mounted.append(target_path) return True, mount_point, "" -def detect_distro_type(mount_point): + +def detect_distro_type(mount_point: str) -> str: """ 尝试检测目标系统发行版类型。 :param mount_point: 目标系统根分区的挂载点 - :return: "arch", "centos", "debian", "ubuntu", "unknown" + :return: "arch", "centos", "debian", "ubuntu", "fedora", "opensuse", "unknown" """ os_release_path = os.path.join(mount_point, "etc/os-release") if not os.path.exists(os_release_path): + # 尝试 /etc/issue 作为备选 + issue_path = os.path.join(mount_point, "etc/issue") + if os.path.exists(issue_path): + try: + with open(issue_path, "r") as f: + content = f.read().lower() + if "ubuntu" in content: + return "ubuntu" + elif "debian" in content: + return "debian" + elif "centos" in content or "rhel" in content: + return "centos" + elif "fedora" in content: + return "fedora" + except Exception: + pass return "unknown" try: with open(os_release_path, "r") as f: content = f.read() - if "ID=arch" in content: - return "arch" - elif "ID=centos" in content or "ID=rhel" in content: - return "centos" - elif "ID=debian" in content: - return "debian" - elif "ID=ubuntu" in content: - return "ubuntu" - else: - return "unknown" + + # 解析 ID 和 ID_LIKE 字段 + id_match = re.search(r'^ID=(.+)$', content, re.MULTILINE) + id_like_match = re.search(r'^ID_LIKE=(.+)$', content, re.MULTILINE) + + distro_id = id_match.group(1).strip('"\'') if id_match else "" + id_like = id_like_match.group(1).strip('"\'') if id_like_match else "" + + # 直接匹配 + if distro_id == "ubuntu": + return "ubuntu" + elif distro_id == "debian": + return "debian" + elif distro_id in ["arch", "manjaro", "endeavouros"]: + return "arch" + elif distro_id in ["centos", "rhel", "rocky", "almalinux"]: + return "centos" + elif distro_id == "fedora": + return "fedora" + elif distro_id in ["opensuse", "opensuse-leap", "opensuse-tumbleweed"]: + return "opensuse" + + # 通过 ID_LIKE 推断 + if "ubuntu" in id_like: + return "ubuntu" + elif "debian" in id_like: + return "debian" + elif "arch" in id_like: + return "arch" + elif "rhel" in id_like or "centos" in id_like or "fedora" in id_like: + return "centos" # 使用centos命令集 + elif "suse" in id_like: + return "opensuse" + + return "unknown" except Exception as e: print(f"[Backend] 无法读取os-release文件: {e}") return "unknown" -def chroot_and_repair_grub(mount_point, target_disk, is_uefi=False, distro_type="unknown"): + +def chroot_and_repair_grub(mount_point: str, target_disk: str, + is_uefi: bool = False, distro_type: str = "unknown") -> Tuple[bool, str]: """ Chroot到目标系统并执行GRUB修复命令。 :param mount_point: 目标系统根分区的挂载点 - :param target_disk: GRUB要安装到的物理磁盘 (例如 /dev/sda) + :param target_disk: GRUB要安装到的物理磁盘 :param is_uefi: 目标系统是否使用UEFI启动 - :param distro_type: 目标系统发行版类型 (用于选择正确的GRUB命令) + :param distro_type: 目标系统发行版类型 :return: (True/False, error_message) """ + if not validate_device_path(target_disk): + return False, f"无效的目标磁盘路径: {target_disk}" + chroot_cmd_prefix = ["sudo", "chroot", mount_point] # 1. 安装GRUB到目标磁盘 if is_uefi: - # UEFI系统,需要确保EFI分区已挂载到 /boot/efi - # grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB - # 注意:某些系统可能需要指定 --removable 选项,以便在某些固件上更容易启动 - # 统一使用 --bootloader-id=GRUB,如果已存在会更新 success, _, stderr = run_command( - chroot_cmd_prefix + ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=GRUB"], + chroot_cmd_prefix + ["grub-install", "--target=x86_64-efi", + "--efi-directory=/boot/efi", "--bootloader-id=GRUB"], "安装UEFI GRUB" ) else: - # BIOS系统,安装到MBR success, _, stderr = run_command( chroot_cmd_prefix + ["grub-install", target_disk], "安装BIOS GRUB" @@ -203,14 +410,30 @@ def chroot_and_repair_grub(mount_point, target_disk, is_uefi=False, distro_type= # 2. 更新GRUB配置文件 grub_update_cmd = [] if distro_type in ["debian", "ubuntu"]: - grub_update_cmd = ["update-grub"] # 这是一个脚本,实际是调用grub-mkconfig + grub_update_cmd = ["update-grub"] elif distro_type == "arch": grub_update_cmd = ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"] elif distro_type == "centos": - grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] # CentOS 8/9 + # 检测实际存在的配置文件路径 + grub2_path = os.path.join(mount_point, "boot/grub2/grub.cfg") + grub_path = os.path.join(mount_point, "boot/grub/grub.cfg") + if os.path.exists(os.path.dirname(grub2_path)): + grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] + else: + grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub/grub.cfg"] + elif distro_type == "fedora": + grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] + elif distro_type == "opensuse": + grub_update_cmd = ["grub2-mkconfig", "-o", "/boot/grub2/grub.cfg"] else: - # 尝试通用命令 - grub_update_cmd = ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"] + # 尝试通用命令,先检测配置文件路径 + 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] + break + else: + grub_update_cmd = ["grub-mkconfig", "-o", "/boot/grub/grub.cfg"] success, _, stderr = run_command( chroot_cmd_prefix + grub_update_cmd, @@ -221,7 +444,8 @@ def chroot_and_repair_grub(mount_point, target_disk, is_uefi=False, distro_type= return True, "" -def unmount_target_system(mount_point): + +def unmount_target_system(mount_point: str) -> Tuple[bool, str]: """ 卸载所有挂载的分区。 :param mount_point: 目标系统根分区的挂载点 @@ -231,17 +455,21 @@ def unmount_target_system(mount_point): success = True error_msg = "" - # 逆序卸载绑定 + # 按照挂载的逆序卸载: + # 挂载顺序: /dev, /dev/pts, /proc, /sys, /run, /boot/efi, /boot, 根分区 + # 卸载顺序: /run, /sys, /proc, /dev/pts, /dev, /boot/efi, /boot, 根分区 + + # 1. 卸载绑定路径(先卸载子目录) bind_paths = ["/run", "/sys", "/proc", "/dev/pts", "/dev"] - for path in reversed(bind_paths): # 逆序 + for path in bind_paths: target_path = os.path.join(mount_point, path.lstrip('/')) - if os.path.ismount(target_path): # 检查是否真的挂载了 + if os.path.ismount(target_path): s, _, stderr = run_command(["sudo", "umount", target_path], f"卸载绑定 {target_path}") if not s: success = False error_msg += f"卸载绑定 {target_path} 失败: {stderr}\n" - # 卸载 /boot/efi (如果存在) + # 2. 卸载 /boot/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_mount_point}") @@ -249,7 +477,7 @@ def unmount_target_system(mount_point): success = False error_msg += f"卸载 {efi_mount_point} 失败: {stderr}\n" - # 卸载 /boot (如果存在) + # 3. 卸载 /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_mount_point}") @@ -257,25 +485,25 @@ def unmount_target_system(mount_point): success = False error_msg += f"卸载 {boot_mount_point} 失败: {stderr}\n" - # 卸载根分区 + # 4. 卸载根分区 if os.path.ismount(mount_point): s, _, stderr = run_command(["sudo", "umount", mount_point], f"卸载根分区 {mount_point}") if not s: success = False error_msg += f"卸载根分区 {mount_point} 失败: {stderr}\n" - # 清理临时挂载点目录 + # 5. 清理临时挂载点目录 if os.path.exists(mount_point) and not os.path.ismount(mount_point): try: - os.rmdir(mount_point) + shutil.rmtree(mount_point) print(f"[Backend] 清理临时目录 {mount_point}") except OSError as e: print(f"[Backend] 无法删除临时目录 {mount_point}: {e}") error_msg += f"无法删除临时目录 {mount_point}: {e}\n" - return success, error_msg + # 示例:如果直接运行backend.py,可以进行一些测试 if __name__ == "__main__": print("--- 运行后端测试 ---") diff --git a/frontend.py b/frontend.py index 5de3f97..d643950 100644 --- a/frontend.py +++ b/frontend.py @@ -3,6 +3,8 @@ import tkinter as tk from tkinter import ttk, messagebox import threading +import os +import re import backend # 导入后端逻辑 class GrubRepairApp: @@ -153,18 +155,31 @@ class GrubRepairApp: self.selected_root_partition_info = self.all_partitions_data.get(selected_display) if self.selected_root_partition_info: - self.log_message(f"已选择根分区: {self.selected_root_partition_info['name']}", "info") - # 尝试根据根分区所在的磁盘自动选择目标磁盘 - # /dev/sda1 -> /dev/sda - disk_name_from_root = "/".join(self.selected_root_partition_info['name'].split('/')[:3]) - 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") - else: + root_part_name = self.selected_root_partition_info['name'] + self.log_message(f"已选择根分区: {root_part_name}", "info") + + # 检查是否是 LVM 逻辑卷 + 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("无法自动选择目标磁盘,请手动选择。", "warning") + self.log_message("检测到 LVM 逻辑卷,请手动选择 GRUB 安装的目标磁盘。", "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") + 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("") @@ -215,71 +230,76 @@ class GrubRepairApp: repair_thread.start() def _run_repair_process(self): - 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'] + 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") + 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") - # 1. 挂载目标系统 - self.log_message("正在挂载目标系统分区...", "info") - 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分区 - ) - if not mount_ok: - self.log_message(f"挂载失败,修复中止: {mount_err}", "error") - messagebox.showerror("修复失败", f"无法挂载目标系统分区。请检查分区选择和权限。\n错误: {mount_err}") - self.start_button.config(state="normal") - return + # 1. 挂载目标系统 + self.log_message("正在挂载目标系统分区...", "info") + 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分区 + ) + if not mount_ok: + self.log_message(f"挂载失败,修复中止: {mount_err}", "error") + self.master.after(0, lambda: messagebox.showerror("修复失败", f"无法挂载目标系统分区。请检查分区选择和权限。\n错误: {mount_err}")) + return - # 2. 尝试识别发行版类型 - self.log_message("正在检测目标系统发行版...", "info") - distro_type = backend.detect_distro_type(self.mount_point) - self.log_message(f"检测到目标系统发行版: {distro_type}", "info") + # 2. 尝试识别发行版类型 + self.log_message("正在检测目标系统发行版...", "info") + distro_type = backend.detect_distro_type(self.mount_point) + self.log_message(f"检测到目标系统发行版: {distro_type}", "info") - # 3. Chroot并修复GRUB - self.log_message("正在进入Chroot环境并修复GRUB...", "info") - repair_ok, repair_err = backend.chroot_and_repair_grub( - self.mount_point, - target_disk_path, - self.is_uefi_mode, - distro_type - ) - if not repair_ok: - self.log_message(f"GRUB修复失败: {repair_err}", "error") - messagebox.showerror("修复失败", f"GRUB修复过程中发生错误。\n错误: {repair_err}") - # 即使失败,也要尝试卸载 - else: - self.log_message("GRUB修复命令执行成功!", "success") + # 3. Chroot并修复GRUB + self.log_message("正在进入Chroot环境并修复GRUB...", "info") + repair_ok, repair_err = backend.chroot_and_repair_grub( + self.mount_point, + target_disk_path, + self.is_uefi_mode, + distro_type + ) + if not repair_ok: + 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") - # 4. 卸载分区 - self.log_message("正在卸载目标系统分区...", "info") - unmount_ok, unmount_err = backend.unmount_target_system(self.mount_point) - if not unmount_ok: - self.log_message(f"卸载分区失败: {unmount_err}", "error") - messagebox.showwarning("卸载警告", f"部分分区可能未能正确卸载。请手动检查并卸载。\n错误: {unmount_err}") - else: - self.log_message("所有分区已成功卸载。", "success") + # 4. 卸载分区 + self.log_message("正在卸载目标系统分区...", "info") + 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}")) + else: + self.log_message("所有分区已成功卸载。", "success") + + self.log_message("--- GRUB 修复过程结束 ---", "info") + + # 最终结果提示 + if repair_ok and unmount_ok: + self.master.after(0, lambda: messagebox.showinfo("修复成功", "GRUB引导修复已完成!请重启系统以验证。")) + elif repair_ok and not unmount_ok: + self.master.after(0, lambda: messagebox.showwarning("修复完成,但有警告", "GRUB引导修复已完成,但部分分区卸载失败。请重启系统以验证。")) + else: + self.master.after(0, lambda: messagebox.showerror("修复失败", "GRUB引导修复过程中发生错误。请检查日志并尝试手动修复。")) - self.log_message("--- GRUB 修复过程结束 ---", "info") - - # 最终结果提示 - if repair_ok and unmount_ok: - messagebox.showinfo("修复成功", "GRUB引导修复已完成!请重启系统以验证。") - elif repair_ok and not unmount_ok: - messagebox.showwarning("修复完成,但有警告", "GRUB引导修复已完成,但部分分区卸载失败。请重启系统以验证。") - else: - messagebox.showerror("修复失败", "GRUB引导修复过程中发生错误。请检查日志并尝试手动修复。") - - self.start_button.config(state="normal") + except Exception as e: + self.log_message(f"发生未预期的错误: {str(e)}", "error") + 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")) if __name__ == "__main__":