Files
BootRepairTool/frontend.py
2026-02-14 14:39:44 +08:00

616 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frontend.py
# 参考 Calamares 引导安装模块优化
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import threading
import os
import re
import backend
# ==============================================================================
# X11 字体渲染问题修复
# 设置 tkinter 使用安全字体,避免 "BadLength" 错误
# ==============================================================================
def setup_safe_fonts():
"""配置安全的字体设置,解决 Arch Linux 等系统上的 X11 渲染问题"""
try:
# 设置 tkinter 使用安全字体
# 避免使用可能导致 X11 RENDER 扩展问题的复杂字体
default_fonts = {
'TkDefaultFont': ('DejaVu Sans', 10),
'TkTextFont': ('DejaVu Sans', 10),
'TkFixedFont': ('DejaVu Sans Mono', 10),
'TkMenuFont': ('DejaVu Sans', 10),
'TkHeadingFont': ('DejaVu Sans', 12, 'bold'),
}
for font_name, font_config in default_fonts.items():
try:
tk.font.nametofont(font_name).configure(family=font_config[0], size=font_config[1])
except:
pass
except:
pass
# 尝试导入字体模块
try:
import tkinter.font as tkfont
except ImportError:
import tkFont as tkfont
class GrubRepairApp:
def __init__(self, master):
self.master = master
# 设置安全字体
setup_safe_fonts()
master.title("Linux GRUB 引导修复工具 v2.0")
master.geometry("643x750")
master.resizable(True, True)
# 设置窗口最小大小
master.minsize(643, 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(
"<Configure>",
lambda e: main_canvas.configure(scrollregion=main_canvas.bbox("all"))
)
main_canvas.create_window((0, 0), window=self.main_frame, anchor="nw")
main_canvas.configure(yscrollcommand=scrollbar.set)
main_canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# 鼠标滚轮支持
def on_mousewheel(event):
main_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
main_canvas.bind_all("<MouseWheel>", on_mousewheel)
# ===== 系统信息区域 =====
self.info_frame = ttk.LabelFrame(self.main_frame, text="系统信息", padding="10")
self.info_frame.pack(padx=10, pady=5, fill="x")
info_text = f"""
架构: {self.system_info.get('cpu_arch', 'Unknown')} |
EFI位数: {self.system_info.get('efi_bits', 'Unknown')}位 |
Live环境: {'' if self.system_info.get('is_live') else ''} |
Secure Boot: {'已启用' if self.system_info.get('sb_enabled') else '已禁用/不支持'}
"""
ttk.Label(self.info_frame, text=info_text.strip()).pack(anchor="w")
# ===== 分区选择区域 =====
self.partition_frame = ttk.LabelFrame(self.main_frame, text="1. 选择目标系统分区", padding="10")
self.partition_frame.pack(padx=10, pady=5, fill="x")
self.partition_frame.columnconfigure(1, weight=1)
# 根分区
ttk.Label(self.partition_frame, text="根分区 (/) *").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.root_partition_combo = ttk.Combobox(self.partition_frame, state="readonly")
self.root_partition_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.root_partition_combo.bind("<<ComboboxSelected>>", 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()