diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2912d62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/build/ diff --git a/__pycache__/disk_operations.cpython-314.pyc b/__pycache__/disk_operations.cpython-314.pyc index de6efbc..493291e 100644 Binary files a/__pycache__/disk_operations.cpython-314.pyc and b/__pycache__/disk_operations.cpython-314.pyc differ diff --git a/__pycache__/lvm_operations.cpython-314.pyc b/__pycache__/lvm_operations.cpython-314.pyc index b26a9f0..f8eb152 100644 Binary files a/__pycache__/lvm_operations.cpython-314.pyc and b/__pycache__/lvm_operations.cpython-314.pyc differ diff --git a/__pycache__/raid_operations.cpython-314.pyc b/__pycache__/raid_operations.cpython-314.pyc index f6d2e0b..cf52a22 100644 Binary files a/__pycache__/raid_operations.cpython-314.pyc and b/__pycache__/raid_operations.cpython-314.pyc differ diff --git a/disk_operations.py b/disk_operations.py index 3bab5d8..59c2ebe 100644 --- a/disk_operations.py +++ b/disk_operations.py @@ -4,8 +4,8 @@ import logging import re import os from PySide6.QtWidgets import QMessageBox, QInputDialog -from PySide6.QtCore import QObject, Signal, QThread # <--- 导入 QObject, Signal, QThread -from system_info import SystemInfoManager # 确保 SystemInfoManager 已导入 +from PySide6.QtCore import QObject, Signal, QThread +from system_info import SystemInfoManager logger = logging.getLogger(__name__) @@ -134,7 +134,7 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 # Use sed for robust removal with sudo # suppress_critical_dialog_on_stderr_match is added to handle cases where fstab might not exist # or sed reports no changes, which is not a critical error for removal. - command = ["sed", "-i", f"/UUID={uuid}/d", fstab_path] + command = ["sed", "-i", f"/{re.escape(uuid)}/d", fstab_path] # Use re.escape for UUID success, _, stderr = self._execute_shell_command( command, f"从 {fstab_path} 中删除 UUID={uuid} 的条目失败", @@ -163,6 +163,106 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 return True # Consider it a success if the entry is not there return False # Other errors are already handled by _execute_shell_command + def _resolve_device_occupation(self, device_path, action_description="操作"): + """ + 尝试解决设备占用问题。 + 检查: + 1. 是否是交换分区,如果是则提示用户关闭。 + 2. 是否有进程占用,如果有则提示用户终止。 + :param device_path: 要检查的设备路径。 + :param action_description: 正在尝试的操作描述,用于用户提示。 + :return: True 如果占用已解决或没有占用,False 如果用户取消或解决失败。 + """ + logger.info(f"尝试解决设备 {device_path} 的占用问题,以便进行 {action_description}。") + + # 1. 检查是否是交换分区 + block_devices = self.system_manager.get_block_devices() + device_info = self.system_manager._find_device_by_path_recursive(block_devices, device_path) + + # Check if it's a swap partition and currently active + if device_info and device_info.get('fstype') == 'swap' and device_info.get('mountpoint') == '[SWAP]': + reply = QMessageBox.question( + None, + "设备占用 - 交换分区", + f"设备 {device_path} 是一个活跃的交换分区。为了进行 {action_description},需要关闭它。您要继续吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + logger.info(f"用户取消了关闭交换分区 {device_path}。") + return False + + logger.info(f"尝试关闭交换分区 {device_path}。") + success, _, stderr = self._execute_shell_command( + ["swapoff", device_path], + f"关闭交换分区 {device_path} 失败", + show_dialog=True # Show dialog for swapoff failure + ) + if not success: + logger.error(f"关闭交换分区 {device_path} 失败: {stderr}") + QMessageBox.critical(None, "错误", f"关闭交换分区 {device_path} 失败。无法进行 {action_description}。") + return False + QMessageBox.information(None, "信息", f"交换分区 {device_path} 已成功关闭。") + # After swapoff, it might still be reported by fuser, so continue to fuser check. + + # 2. 检查是否有进程占用 (使用 fuser) + success_fuser, stdout_fuser, stderr_fuser = self._execute_shell_command( + ["fuser", "-vm", device_path], + f"检查设备 {device_path} 占用失败", + show_dialog=False, # Don't show dialog if fuser fails (e.g., device not busy) + suppress_critical_dialog_on_stderr_match=( + "No such file or directory", # if device doesn't exist + "not found", # if fuser itself isn't found + "Usage:" # if fuser gets invalid args, though unlikely here + ) + ) + + pids = [] + process_info_lines = [] + # Parse fuser output only if it was successful and has stdout + if success_fuser and stdout_fuser: + for line in stdout_fuser.splitlines(): + # Example: "/dev/sdb1: root 12078 .rce. gpg-agent" + match = re.match(r'^\S+:\s+\S+\s+(\d+)\s+.*', line) + if match: + pid = match.group(1) + pids.append(pid) + process_info_lines.append(line.strip()) + + if pids: + process_list_str = "\n".join(process_info_lines) + reply = QMessageBox.question( + None, + "设备占用 - 进程", + f"设备 {device_path} 正在被以下进程占用:\n{process_list_str}\n\n您要强制终止这些进程吗?这可能会导致数据丢失或系统不稳定!", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + logger.info(f"用户取消了终止占用设备 {device_path} 的进程。") + return False + + logger.info(f"尝试终止占用设备 {device_path} 的进程: {', '.join(pids)}。") + all_killed = True + for pid in pids: + kill_success, _, kill_stderr = self._execute_shell_command( + ["kill", "-9", pid], + f"终止进程 {pid} 失败", + show_dialog=True # Show dialog for kill failure + ) + if not kill_success: + logger.error(f"终止进程 {pid} 失败: {kill_stderr}") + all_killed = False + if not all_killed: + QMessageBox.critical(None, "错误", f"未能终止所有占用设备 {device_path} 的进程。无法进行 {action_description}。") + return False + QMessageBox.information(None, "信息", f"已尝试终止占用设备 {device_path} 的所有进程。") + else: + logger.info(f"设备 {device_path} 未被任何进程占用。") + + logger.info(f"设备 {device_path} 的占用问题已尝试解决。") + return True + def mount_partition(self, device_path, mount_point, add_to_fstab=False): """ 挂载指定设备到指定挂载点。 @@ -206,43 +306,66 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 return False def unmount_partition(self, device_path, show_dialog_on_error=True): - """ - 卸载指定设备。 - :param device_path: 要卸载的设备路径。 - :param show_dialog_on_error: 是否在发生错误时显示对话框。 - :return: True 如果成功,否则 False。 - """ - logger.info(f"尝试卸载设备 {device_path}。") - # 定义表示设备已未挂载的错误信息(中英文) - # 增加了 "未指定挂载点" - already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点") + """ + 卸载指定设备。 + :param device_path: 要卸载的设备路径。 + :param show_dialog_on_error: 是否在发生错误时显示对话框。 + :return: True 如果成功,否则 False。 + """ + logger.info(f"尝试卸载设备 {device_path}。") + # 定义表示设备已未挂载的错误信息(中英文) + already_unmounted_errors = ("not mounted", "未挂载", "未指定挂载点") + device_busy_errors = ("device is busy", "设备或资源忙", "Device or resource busy") # Common "device busy" messages - # 调用 _execute_shell_command,并告诉它在遇到“已未挂载”错误时,不要弹出其自身的关键错误对话框。 - success, _, stderr = self._execute_shell_command( - ["umount", device_path], - f"卸载设备 {device_path} 失败", - suppress_critical_dialog_on_stderr_match=already_unmounted_errors, - show_dialog=show_dialog_on_error # <--- 将 show_dialog_on_error 传递给底层命令执行器 - ) + # First attempt to unmount + success, _, stderr = self._execute_shell_command( + ["umount", device_path], + f"卸载设备 {device_path} 失败", + suppress_critical_dialog_on_stderr_match=already_unmounted_errors + device_busy_errors, # Suppress both types of errors for initial attempt + show_dialog=False # Don't show dialog on first attempt, we'll handle it + ) - if success: - # 如果命令成功执行,则卸载成功。 - QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") - return True - else: - # 如果命令失败,检查是否是因为设备已经未挂载或挂载点未指定。 - is_already_unmounted_error = any(s in stderr for s in already_unmounted_errors) + if success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") + return True + else: + # Check if it failed because it was already unmounted + if any(s in stderr for s in already_unmounted_errors): + logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。") + return True # Consider it a success - if is_already_unmounted_error: - logger.info(f"设备 {device_path} 已经处于未挂载状态(或挂载点未指定),无需重复卸载。") - # 这种情况下,操作结果符合预期(设备已是未挂载),不弹出对话框,并返回 True。 - return True + # Check if it failed because the device was busy + if any(s in stderr for s in device_busy_errors): + logger.warning(f"卸载设备 {device_path} 失败,设备忙。尝试解决占用问题。") + # Call the new helper to resolve occupation + if self._resolve_device_occupation(device_path, action_description=f"卸载 {device_path}"): + logger.info(f"设备 {device_path} 占用问题已解决,重试卸载。") + # Retry unmount after resolving occupation + retry_success, _, retry_stderr = self._execute_shell_command( + ["umount", device_path], + f"重试卸载设备 {device_path} 失败", + suppress_critical_dialog_on_stderr_match=already_unmounted_errors, + show_dialog=show_dialog_on_error # Show dialog if retry fails for other reasons + ) + if retry_success: + QMessageBox.information(None, "成功", f"设备 {device_path} 已成功卸载。") + return True + else: + logger.error(f"重试卸载设备 {device_path} 失败: {retry_stderr}") + if show_dialog_on_error and not any(s in retry_stderr for s in already_unmounted_errors): + QMessageBox.critical(None, "错误", f"重试卸载设备 {device_path} 失败。\n错误详情: {retry_stderr}") + return False else: - # 对于其他类型的卸载失败(例如设备忙、权限不足等), - # _execute_shell_command 应该已经弹出了关键错误对话框 - # (因为它没有匹配到 `already_unmounted_errors` 进行抑制)。 - # 所以,这里我们不需要再次弹出对话框,直接返回 False。 + logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题。") + if show_dialog_on_error: + QMessageBox.critical(None, "错误", f"未能解决设备 {device_path} 的占用问题,无法卸载。") return False + else: + # Other types of unmount failures + logger.error(f"卸载设备 {device_path} 失败: {stderr}") + if show_dialog_on_error: + QMessageBox.critical(None, "错误", f"卸载设备 {device_path} 失败。\n错误详情: {stderr}") + return False def get_disk_free_space_info_mib(self, disk_path, total_disk_mib): """ @@ -317,6 +440,11 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 QMessageBox.critical(None, "错误", "无效的磁盘路径或分区表类型。") return False + # NEW: 尝试解决磁盘占用问题 + if not self._resolve_device_occupation(disk_path, action_description=f"在 {disk_path} 上创建分区"): + logger.info(f"用户取消或未能解决磁盘 {disk_path} 的占用问题,取消创建分区。") + return False + # 1. 检查磁盘是否有分区表 has_partition_table = False # Use suppress_critical_dialog_on_stderr_match for "unrecognized disk label" when checking @@ -419,6 +547,11 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 :param device_path: 要删除的分区路径。 :return: True 如果成功,否则 False。 """ + # NEW: 尝试解决分区占用问题 + if not self._resolve_device_occupation(device_path, action_description=f"删除分区 {device_path}"): + logger.info(f"用户取消或未能解决分区 {device_path} 的占用问题,取消删除分区。") + return False + reply = QMessageBox.question(None, "确认删除分区", f"您确定要删除分区 {device_path} 吗?此操作将擦除分区上的所有数据!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) @@ -426,10 +559,8 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.info(f"用户取消了删除分区 {device_path} 的操作。") return False - # 尝试卸载分区 - # The unmount_partition method now handles "not mounted" gracefully without dialog and returns True. - # So, we just call it. - self.unmount_partition(device_path, show_dialog_on_error=False) # show_dialog_on_error=False means no dialog for other errors too. + # 尝试卸载分区 (此方法内部会尝试解决占用问题) + self.unmount_partition(device_path, show_dialog_on_error=False) # 从 fstab 中移除条目 # _remove_fstab_entry also handles "not found" gracefully. @@ -471,6 +602,11 @@ class DiskOperations(QObject): # <--- 继承 QObject 以便发出信号 logger.info("用户取消了文件系统选择。") return False + # NEW: 尝试解决设备占用问题 + if not self._resolve_device_occupation(device_path, action_description=f"格式化设备 {device_path}"): + logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题,取消格式化。") + return False + reply = QMessageBox.question(None, "确认格式化", f"您确定要格式化设备 {device_path} 为 {fstype} 吗?此操作将擦除所有数据!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) diff --git a/lvm_operations.py b/lvm_operations.py index 0d8e00e..b1c6b86 100644 --- a/lvm_operations.py +++ b/lvm_operations.py @@ -6,8 +6,8 @@ from PySide6.QtWidgets import QMessageBox logger = logging.getLogger(__name__) class LvmOperations: - def __init__(self): - pass + def __init__(self, disk_ops=None): # <--- Add disk_ops parameter + self.disk_ops = disk_ops # Store disk_ops instance def _execute_shell_command(self, command_list, error_message, root_privilege=True, suppress_critical_dialog_on_stderr_match=None, input_data=None, @@ -92,6 +92,15 @@ class LvmOperations: QMessageBox.critical(None, "错误", "无效的设备路径。") return False + # NEW: 尝试解决设备占用问题 + if self.disk_ops: # Ensure disk_ops is available + if not self.disk_ops._resolve_device_occupation(device_path, action_description=f"在 {device_path} 上创建物理卷"): + logger.info(f"用户取消或未能解决设备 {device_path} 的占用问题,取消创建物理卷。") + return False + else: + logger.warning("DiskOperations 实例未传递给 LvmOperations,无法解决设备占用问题。") + + reply = QMessageBox.question(None, "确认创建物理卷", f"您确定要在设备 {device_path} 上创建物理卷吗?此操作将覆盖设备上的数据。", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) diff --git a/mainwindow.py b/mainwindow.py index 165d452..8224d65 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -36,11 +36,12 @@ class MainWindow(QMainWindow): # 初始化管理器和操作类 self.system_manager = SystemInfoManager() - self.lvm_ops = LvmOperations() # <--- 先初始化 LvmOperations - # <--- 将 lvm_ops 实例传递给 DiskOperations - self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops) - self.raid_ops = RaidOperations() - # self.lvm_ops = LvmOperations() # 这一行是重复的,可以删除 + # Correct order for dependency injection: + self.lvm_ops = LvmOperations() # Initialize LVM first + self.disk_ops = DiskOperations(self.system_manager, self.lvm_ops) # DiskOperations needs system_manager and lvm_ops + self.lvm_ops.disk_ops = self.disk_ops # Inject disk_ops into lvm_ops + self.raid_ops = RaidOperations(self.system_manager, self.disk_ops) # RaidOperations needs system_manager and disk_ops + # 连接刷新按钮的信号到槽函数 if hasattr(self.ui, 'refreshButton'): @@ -188,6 +189,11 @@ class MainWindow(QMainWindow): """ 处理擦除物理盘分区表的操作。 """ + # NEW: 尝试解决磁盘占用问题 + if not self.disk_ops._resolve_device_occupation(device_path, action_description=f"擦除 {device_path} 上的分区表"): + logger.info(f"用户取消或未能解决磁盘 {device_path} 的占用问题,取消擦除分区表。") + return + reply = QMessageBox.question( self, "确认擦除分区表", @@ -455,7 +461,7 @@ class MainWindow(QMainWindow): array_uuid = array_data.get('uuid') if not array_uuid or array_uuid == 'N/A': - QMessageBox.critical(self, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。") + QMessageBox.critical(None, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。") logger.error(f"激活 RAID 阵列 {array_path} 失败:缺少 UUID。") return @@ -494,7 +500,7 @@ class MainWindow(QMainWindow): def _handle_delete_active_raid_array(self, array_path, member_devices, uuid): # <--- 修改方法名并添加 uuid 参数 """ - 处理删除活动 RAID 阵列的操作。 + 处理删除一个活动的 RAID 阵列的操作。 """ if self.raid_ops.delete_active_raid_array(array_path, member_devices, uuid): # <--- 调用修改后的方法 self.refresh_all_info() @@ -850,4 +856,3 @@ if __name__ == "__main__": widget = MainWindow() widget.show() sys.exit(app.exec()) - diff --git a/raid_operations.py b/raid_operations.py index 73c69ff..8d7fa1b 100644 --- a/raid_operations.py +++ b/raid_operations.py @@ -7,12 +7,15 @@ from system_info import SystemInfoManager import re import pexpect # <--- 新增导入 pexpect import sys +# NEW: 导入 DiskOperations +from disk_operations import DiskOperations logger = logging.getLogger(__name__) class RaidOperations: - def __init__(self): - self.system_manager = SystemInfoManager() + def __init__(self, system_manager: SystemInfoManager, disk_ops: DiskOperations): # <--- Add system_manager and disk_ops parameters + self.system_manager = system_manager + self.disk_ops = disk_ops # Store disk_ops instance def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None): """ @@ -165,7 +168,7 @@ class RaidOperations: # 等待一小段时间,确保 mdadm 接收并处理了输入,并清空缓冲区 # 避免在下一次 expect 之前,旧的输出再次被匹配 - child.expect(pexpect.TIMEOUT, timeout=1) + # child.expect(pexpect.TIMEOUT, timeout=1) # This line might consume output needed for next prompt except pexpect.exceptions.TIMEOUT: # 如果在等待任何提示时超时,说明没有更多提示了,或者命令已完成。 @@ -179,7 +182,6 @@ class RaidOperations: break # 跳出 while 循环,进入等待 EOF 阶段 - try: # 增加一个合理的超时时间,以防 mdadm 在后台进行长时间初始化 child.expect(pexpect.EOF, timeout=120) # 例如,等待 120 秒 @@ -277,6 +279,11 @@ class RaidOperations: QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。") return False + # NEW: 尝试解决所有成员设备的占用问题 + for dev in devices: + if not self.disk_ops._resolve_device_occupation(dev, action_description=f"创建 RAID 阵列,成员 {dev}"): + logger.info(f"用户取消或未能解决设备 {dev} 的占用问题,取消创建 RAID 阵列。") + return False # 确认操作 reply = QMessageBox.question(None, "确认创建 RAID 阵列", @@ -358,6 +365,7 @@ class RaidOperations: # 这里需要确保 /etc/mdadm.conf 存在且可写入 # _execute_shell_command 会自动添加 sudo + # 使用 tee -a 而不是 >> 来确保 sudo 权限下的写入 success_append, _, _ = self._execute_shell_command( ["bash", "-c", f"echo '{scan_stdout.strip()}' | tee -a /etc/mdadm.conf > /dev/null"], "更新 /etc/mdadm/mdadm.conf 失败" @@ -387,13 +395,11 @@ class RaidOperations: logger.info(f"尝试停止 RAID 阵列: {array_path}") - # 尝试卸载阵列(如果已挂载),不显示错误对话框 - # _execute_shell_command 会自动添加 sudo - self._execute_shell_command( - ["umount", array_path], - f"尝试卸载 {array_path} 失败", - suppress_critical_dialog_on_stderr_match=("not mounted", "未挂载") # 修改这里,添加中文错误信息 - ) + # NEW: 尝试卸载阵列(如果已挂载),利用 DiskOperations 的增强卸载功能 + # unmount_partition 内部会尝试解决设备占用问题 + if not self.disk_ops.unmount_partition(array_path, show_dialog_on_error=False): + logger.warning(f"未能成功卸载 RAID 阵列 {array_path}。尝试继续停止操作。") + # 即使卸载失败,我们仍然尝试停止 mdadm 阵列,因为有时 mdadm --stop 可以在设备忙时强制停止。 if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]: return False @@ -421,6 +427,7 @@ class RaidOperations: logger.info(f"尝试删除活动 RAID 阵列: {array_path} (UUID: {uuid})") # 1. 停止阵列 + # stop_raid_array 内部会调用 self.disk_ops.unmount_partition 来尝试卸载并解决占用 if not self.stop_raid_array(array_path): QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 失败,因为无法停止阵列。") return False