# frontend.py # 参考 Calamares 引导安装模块优化 import tkinter as tk from tkinter import ttk, messagebox, scrolledtext import threading import os import re import backend class GrubRepairApp: def __init__(self, master): self.master = master 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_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 = {} 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): """创建 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) # 根分区 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=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)) # /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=2, column=1, padx=5, pady=5, sticky="ew") # 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=3, column=1, padx=5, pady=5, sticky="ew") # ===== 目标磁盘选择 ===== 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="目标磁盘 *:").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=5, sticky="ew") # 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() 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") # 如果 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") # ===== 操作按钮 ===== self.button_frame = ttk.Frame(self.main_frame) self.button_frame.pack(padx=10, pady=10, fill="x") self.start_button = ttk.Button( self.button_frame, text="🔧 开始修复 GRUB", command=self.start_repair_thread ) self.start_button.pack(side="left", padx=(0, 5)) self.rescan_button = ttk.Button( self.button_frame, text="🔄 重新扫描分区", command=self.scan_partitions_gui ) self.rescan_button.pack(side="left", padx=5) # ===== 日志区域 ===== self.log_frame = ttk.LabelFrame(self.main_frame, text="修复日志", padding="10") self.log_frame.pack(padx=10, pady=5, fill="both", expand=True) self.log_text = 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_text.tag_config("debug", foreground="gray") self.log_text.tag_config("info", foreground="black") self.log_text.tag_config("warning", foreground="orange") self.log_text.tag_config("error", foreground="red") self.log_text.tag_config("success", foreground="green") self.log_text.tag_config("step", foreground="blue", font=("Consolas", 10, "bold")) def _backend_log_callback(self, message: str, level): """后端日志回调函数""" level_str = level.value if hasattr(level, 'value') else str(level).lower() self._append_log(message, level_str) def log_message(self, message: str, level: str = "info"): """线程安全的日志记录""" self.master.after(0, self._append_log, message, level) def _append_log(self, message: str, level: str = "info"): """添加日志到文本框""" self.log_text.config(state="normal") # 添加时间戳 import datetime timestamp = datetime.datetime.now().strftime("%H:%M:%S") log_line = f"[{timestamp}] {message}\n" 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 # 清空旧数据 self.all_disks_data = {} self.all_partitions_data = {} self.all_efi_partitions_data = {} # 更新磁盘列表 disk_display_names = [] for d in disks: 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: 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 # 更新 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 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) if self.selected_root_partition_info: root_part_name = self.selected_root_partition_info['name'] self.log_message(f"已选择根分区: {root_part_name}", "info") # 检测是否为 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: self.target_disk_combo.set("") self.selected_target_disk_info = None self.log_message("检测到 LVM 逻辑卷,请手动选择目标磁盘", "warning") else: # 根据分区路径推断磁盘 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 else: self.selected_root_partition_info = None self.target_disk_combo.set("") self.selected_target_disk_info = None self.btrfs_frame.grid_remove() 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") # 如果启用 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("=" * 60, "step") self.log_message("GRUB 引导修复过程开始", "step") self.log_message("=" * 60, "step") # 启动修复线程 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'] 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("正在挂载目标系统分区...", "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, 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}")) return # 2. 检测发行版 self.log_message("正在检测目标系统发行版...", "step") distro_type = backend.detect_distro_type(self.mount_point) self.log_message(f"检测到发行版: {distro_type}", "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") # 检查是否有独立的 /boot 分区 has_separate_boot = self.selected_boot_partition_info is not None repair_ok, repair_err = backend.chroot_and_repair_grub( self.mount_point, target_disk_path or "", # UEFI 模式下可能为空 self.is_uefi_mode, distro_type, install_hybrid=install_hybrid, use_fallback=use_fallback, efi_bootloader_id=bootloader_id, has_separate_boot=has_separate_boot ) 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("正在卸载目标系统分区...", "step") unmount_ok, unmount_err = backend.unmount_target_system(self.mount_point) if not unmount_ok: self.log_message(f"卸载分区失败: {unmount_err}", "warning") else: self.log_message("所有分区已成功卸载", "success") 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 引导修复已完成!\n\n" "请重启系统以验证修复结果。\n" "如果仍无法启动,请检查BIOS设置中的启动顺序。" )) elif repair_ok and not unmount_ok: self.master.after(0, lambda: messagebox.showwarning( "修复完成,但有警告", "GRUB 引导修复已完成,但部分分区卸载失败。\n" "建议重启系统,这会自动清理挂载点。" )) else: 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()