From 350bcb691cafef6b49211e731a7d14c9f405fa46 Mon Sep 17 00:00:00 2001 From: HenryYannis <247510158+HenryYannis@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:52:54 +0000 Subject: [PATCH 1/5] =?UTF-8?q?Python=20=E7=89=88=EF=BC=9A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=91=BD=E4=BB=A4=E7=94=A8=E7=BB=9D=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E3=80=81=E5=8F=AA=E5=85=B3=E5=BD=93=E5=89=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=9A=84=20Office=E3=80=81=E6=A0=B8=E5=AF=B9=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E8=B7=AF=E5=BE=84=E7=AD=89=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 遇到的问题(审计复核确认属实或部分属实的几条): - #1 reg/taskkill/sc/net/schtasks/tasklist 只写了文件名,管理员运行时会先在 exe 所在目录里找同名程序; - #3 taskkill /IM 会结束所有登录用户的 Word/Excel 等; - #8 OfficePLUS 进程只按名字匹配,DeepLink.exe 这类常见名字可能误杀; - #9 子进程没有超时,net stop 没加 /y,也没重定向标准输入; - #10 先删服务再备份注册表,和 PowerShell 版顺序不一致; - #12 服务处于 *_PENDING 状态时被当成不存在; - #13 手写 CSV 解析,一行格式不对会让后面的进程全部漏检; - #14 32 位 Python 下 ProgramFiles 指向 Program Files (x86); - #16 管理员往用户可控的「文档」里写备份和日志,没检查是不是联接。 我的改动: - 新增 winutil.py:system_exe() 用 GetSystemDirectoryW 拿到 System32 绝对路径; list_processes() 用 csv.reader 解析 tasklist(含会话号),单行出错只跳过该行; process_path() 读进程程序路径;is_reparse_point() 判断联接/符号链接;COMMAND_TIMEOUT=60 秒。 - cleaner.kill_processes:Office 程序只按 PID 结束当前会话里的;OfficePLUS 进程不分会话 (服务进程在会话 0),但程序所在文件夹路径必须含 OfficePLUS,读不到路径就跳过并记日志。 - 所有 subprocess 调用加 stdin=DEVNULL 和 timeout,net stop 加 /y;超时按失败记进日志。 - run_nuke 先备份注册表,再结束进程、删服务。 - scan_services 改看 sc query 的退出码(0 存在、1060 不存在)。 - 目录规则的 ProgramFiles 优先取 ProgramW6432。 - 备份文件夹 / 日志文件夹是重解析点时拒绝写入(备份按写入失败处理,不删注册表)。 - E2E 假进程放进名字含 OfficePLUS 的临时文件夹,符合新的路径核对。 没改 gui.py(AGENTS.md 硬性规则 8)。 怎么测试的:py_compile、sync_rules --check、real_sample_check 通过;在 Linux 上用 mock 跑了 10 个逻辑测试(CSV 解析、会话过滤、路径核对、PENDING 状态、超时、执行顺序、联接拒写、ProgramW6432)。 没有 Windows 机器,未实机测试,需要 CI/E2E 验证。 --- src/remove_officeplus/backup.py | 27 +++++-- src/remove_officeplus/cleaner.py | 94 +++++++++++++++-------- src/remove_officeplus/constants.py | 7 +- src/remove_officeplus/runlog.py | 3 + src/remove_officeplus/scanner.py | 61 +++++++++------ src/remove_officeplus/winutil.py | 119 +++++++++++++++++++++++++++++ tests/e2e/fake_residue.py | 6 +- 7 files changed, 248 insertions(+), 69 deletions(-) create mode 100644 src/remove_officeplus/winutil.py diff --git a/src/remove_officeplus/backup.py b/src/remove_officeplus/backup.py index b8914db..342e328 100644 --- a/src/remove_officeplus/backup.py +++ b/src/remove_officeplus/backup.py @@ -7,6 +7,7 @@ from .user_context import get_target_user, resolve_key from . import runlog +from .winutil import COMMAND_TIMEOUT, is_reparse_point, system_exe BACKUP_FOLDER_NAME = "Remove-OfficePlus 备份" REG_HEADER = "Windows Registry Editor Version 5.00" @@ -80,26 +81,36 @@ def backup_registry(keys: Iterable[Tuple[str, str]]) -> Tuple[Optional[str], Set with tempfile.TemporaryDirectory() as tmp: for i, (subkey, root_name) in enumerate(keys): part = os.path.join(tmp, f"{i}.reg") - res = subprocess.run( - ["reg.exe", "export", resolve_key(subkey, root_name)[2], part, "/y", "/reg:64"], - creationflags=subprocess.CREATE_NO_WINDOW, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if res.returncode == 0 and os.path.exists(part): + try: + res = subprocess.run( + [system_exe("reg.exe"), "export", resolve_key(subkey, root_name)[2], part, "/y", "/reg:64"], + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=COMMAND_TIMEOUT, + ) + code = res.returncode + except Exception as e: # 超时等:按备份失败处理,这一项不删 + code = repr(e) + if code == 0 and os.path.exists(part): with open(part, encoding="utf-16") as fh: text = fh.read() # 去掉每个分段自带的文件头,合并后只保留一个 parts.append(text.replace(REG_HEADER, "", 1).strip("\r\n")) else: failed.add((subkey, root_name)) - runlog.add(f"[失败] 导出注册表项 {resolve_key(subkey, root_name)[2]},退出码 {res.returncode}") + runlog.add(f"[失败] 导出注册表项 {resolve_key(subkey, root_name)[2]},退出码 {code}") if not parts: return None, failed backup_dir = get_backup_dir() try: + # 管理员身份往用户可控的「文档」里写:备份文件夹是联接/符号链接时拒绝写入, + # 免得被引到系统目录里。宁可这次不清注册表。 + if is_reparse_point(backup_dir): + raise OSError(f"备份文件夹是联接或符号链接,拒绝写入: {backup_dir}") os.makedirs(backup_dir, exist_ok=True) path = _write_backup_file(backup_dir, REG_HEADER + "\n\n" + "\n\n".join(parts) + "\n") except Exception as e: diff --git a/src/remove_officeplus/cleaner.py b/src/remove_officeplus/cleaner.py index 0604e10..f927965 100644 --- a/src/remove_officeplus/cleaner.py +++ b/src/remove_officeplus/cleaner.py @@ -5,12 +5,14 @@ import subprocess import ctypes from typing import List, Tuple -from .constants import TARGET_PROCESSES, get_target_directories +from .constants import OFFICEPLUS_PROCESSES, OFFICEPLUS_KEYWORD, TARGET_PROCESSES, get_target_directories from .scanner import is_admin, list_scheduled_tasks, is_officeplus_task from .backup import backup_registry, get_backup_dir from . import runlog from .user_context import resolve_key from .discovery import get_registry_keys, get_services +from .winutil import (COMMAND_TIMEOUT, current_session_id, is_officeplus_image, list_processes, + process_path, system_exe) # 32 位程序(如 32 位 exe)访问 HKLM\Software 会被重定向到 Wow6432Node, # 一律指定 64 位视图;32 位那份由规则里的 Wow6432Node 路径单独覆盖 @@ -53,39 +55,64 @@ def elevate() -> bool: print("[!] 没有获得管理员权限(可能在 UAC 提示中选择了「否」)。", file=sys.stderr) return False +def _run(args): + """运行系统命令:不读标准输入(不会等用户输入 Y/N),超时会抛 TimeoutExpired,由调用方记进日志。""" + return subprocess.run( + args, + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + capture_output=True, text=True, errors="replace", + timeout=COMMAND_TIMEOUT, + ) + def kill_processes() -> List[str]: + """结束 OfficePLUS 进程和占用加载项文件的 Office 程序。 + + - Word/Excel 等正常的 Office 程序只结束当前会话(当前登录用户)里的, + 不碰同一台电脑上其他用户的 Office,避免他们没保存的文档丢失; + - OfficePLUS 自己的进程(服务进程在会话 0)不分会话,但要核对程序路径在 OfficePLUS 的文件夹里, + 路径读不到就不结束,避免误杀其他软件的同名进程。 + """ killed = [] - for proc in TARGET_PROCESSES: - try: - res = subprocess.run( - ["taskkill", "/F", "/IM", proc], - creationflags=subprocess.CREATE_NO_WINDOW, - capture_output=True, text=True, errors="replace" - ) - if res.returncode == 0: # 没在运行或结束失败都不算 - killed.append(proc) - runlog.add(f"已结束进程: {proc}") - elif res.returncode != 128: # 128 = 没有这个进程 - runlog.add(f"[失败] 结束进程 {proc}: {runlog.output_text(res)}") - except Exception as e: - runlog.add(f"[失败] 结束进程 {proc}: {e}") + try: + procs = list_processes() + except Exception as e: + runlog.add(f"[失败] 读取进程列表: {e}") + return killed + my_session = current_session_id() + officeplus = {p.lower() for p in OFFICEPLUS_PROCESSES} + for target in TARGET_PROCESSES: + for proc in procs: + if proc.name.lower() != target.lower(): + continue + label = f"{proc.name} (PID: {proc.pid})" + if target.lower() in officeplus: + path = process_path(proc.pid) + if not is_officeplus_image(path, OFFICEPLUS_KEYWORD): + runlog.add(f"[跳过] 进程 {label} 的程序路径不属于 OfficePLUS,未结束: {path or '路径未知'}") + continue + elif my_session is None or proc.session != my_session: + runlog.add(f"[跳过] 进程 {label} 不在当前会话(其他用户的 Office),未结束") + continue + try: + res = _run([system_exe("taskkill.exe"), "/F", "/PID", str(proc.pid)]) + if res.returncode == 0: + if target not in killed: + killed.append(target) + runlog.add(f"已结束进程: {label}") + elif res.returncode != 128: # 128 = 进程已经不在了 + runlog.add(f"[失败] 结束进程 {label}: {runlog.output_text(res)}") + except Exception as e: + runlog.add(f"[失败] 结束进程 {label}: {e}") return killed def delete_services() -> List[str]: removed = [] for svc in get_services(): try: - subprocess.run( - ["net", "stop", svc], - creationflags=subprocess.CREATE_NO_WINDOW, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - res = subprocess.run( - ["sc.exe", "delete", svc], - creationflags=subprocess.CREATE_NO_WINDOW, - capture_output=True, text=True, errors="replace" - ) + # /y:有依赖服务时自动确认,不等用户输入 + _run([system_exe("net.exe"), "stop", svc, "/y"]) + res = _run([system_exe("sc.exe"), "delete", svc]) if res.returncode == 0: removed.append(svc) runlog.add(f"已删除服务: {svc}") @@ -159,11 +186,11 @@ def clean_scheduled_tasks() -> List[str]: for task_name in task_names: if not is_officeplus_task(task_name): continue - res = subprocess.run( - ["schtasks", "/Delete", "/TN", task_name, "/F"], - creationflags=subprocess.CREATE_NO_WINDOW, - capture_output=True, text=True, errors="replace" - ) + try: + res = _run([system_exe("schtasks.exe"), "/Delete", "/TN", task_name, "/F"]) + except Exception as e: + runlog.add(f"[失败] 删除计划任务 {task_name}: {e}") + continue if res.returncode == 0: cleaned.append(task_name) runlog.add(f"已删除计划任务: {task_name}") @@ -206,12 +233,13 @@ def run_nuke() -> Tuple[List[str], List[str], List[str], List[str], List[str]]: procs, svcs, regs, tasks, dirs = [], [], [], [], [] runlog.start() try: - procs = kill_processes() - svcs = delete_services() + # 先备份注册表再动手(和 PowerShell 版顺序一致) existing = [(k, r) for k, r in get_registry_keys() if _reg_key_exists(*resolve_key(k, r)[:2])] LAST_BACKUP_PATH, backup_failed = backup_registry(existing) if LAST_BACKUP_PATH: runlog.add(f"注册表备份: {LAST_BACKUP_PATH}") + procs = kill_processes() + svcs = delete_services() regs = clean_registry(skip=backup_failed, keys=existing) tasks = clean_scheduled_tasks() dirs = delete_directories() diff --git a/src/remove_officeplus/constants.py b/src/remove_officeplus/constants.py index 92f78a2..b8eb627 100644 --- a/src/remove_officeplus/constants.py +++ b/src/remove_officeplus/constants.py @@ -21,6 +21,9 @@ TARGET_SERVICES = list(RULES["services"]) +# OfficePLUS 自己的进程,程序所在文件夹的路径里必须含有这个词(不区分大小写)才会被结束 +OFFICEPLUS_KEYWORD = RULES["addin_keyword"] + # 计划任务名或路径里含有这个词(不区分大小写)就算 OfficePLUS 的 TASK_KEYWORD = RULES["task_keyword"] @@ -34,7 +37,9 @@ def get_target_directories(): # 绝不能拼成盘符根目录下的路径。 user = get_target_user() bases = { - "ProgramFiles": os.environ.get("ProgramFiles"), + # 32 位 Python 在 64 位系统上,ProgramFiles 会被换成 Program Files (x86), + # ProgramW6432 才是真正的 64 位 Program Files + "ProgramFiles": os.environ.get("ProgramW6432") or os.environ.get("ProgramFiles"), "ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)"), "ProgramData": os.environ.get("ProgramData"), "LocalAppData": user.local_appdata, diff --git a/src/remove_officeplus/runlog.py b/src/remove_officeplus/runlog.py index 00b21f4..60098ba 100644 --- a/src/remove_officeplus/runlog.py +++ b/src/remove_officeplus/runlog.py @@ -12,6 +12,7 @@ from typing import List, Optional from . import __version__ +from .winutil import is_reparse_point _lines: List[str] = [] # 最近一次清理的日志文件路径(没写成功时为 None) @@ -41,6 +42,8 @@ def save(folder: str) -> Optional[str]: """写入 folder\\清理日志_时间.txt,同一秒多次运行时加 _2、_3,不覆盖旧日志。""" global LAST_LOG_PATH try: + if is_reparse_point(folder): # 联接/符号链接:不写,免得管理员把文件写到别处 + raise OSError(f"日志文件夹是联接或符号链接,拒绝写入: {folder}") os.makedirs(folder, exist_ok=True) stem = f"清理日志_{datetime.now():%Y%m%d_%H%M%S}" n = 1 diff --git a/src/remove_officeplus/scanner.py b/src/remove_officeplus/scanner.py index 9433bab..f054709 100644 --- a/src/remove_officeplus/scanner.py +++ b/src/remove_officeplus/scanner.py @@ -8,7 +8,9 @@ from typing import List from .user_context import resolve_key from .discovery import get_registry_keys, get_services -from .constants import OFFICEPLUS_PROCESSES, OFFICE_APP_PROCESSES, TASK_KEYWORD, get_target_directories +from .constants import OFFICEPLUS_PROCESSES, OFFICE_APP_PROCESSES, TASK_KEYWORD, OFFICEPLUS_KEYWORD, get_target_directories +from .winutil import (COMMAND_TIMEOUT, current_session_id, is_officeplus_image, list_processes, + process_path, system_exe) @dataclass class ScanReport: @@ -35,37 +37,44 @@ def is_admin() -> bool: except Exception: return False -def scan_processes(targets=OFFICEPLUS_PROCESSES) -> List[str]: +def scan_processes(targets=OFFICEPLUS_PROCESSES, session_only=False) -> List[str]: + """列出正在运行的目标进程。 + + OfficePLUS 自己的进程还要核对程序路径:DeepLink.exe 这类名字很常见,路径不在 OfficePLUS + 的文件夹里就不算;没权限读路径时(例如未提权扫描系统服务进程)照样报告,宁可多报。 + session_only=True 时只看当前会话(清理时只关当前用户的 Office,提醒保存也只针对他)。 + """ found = [] - try: - output = subprocess.check_output( - ["tasklist", "/FO", "CSV", "/NH"], - creationflags=subprocess.CREATE_NO_WINDOW, - encoding="utf-8", - errors="ignore" - ) - for line in output.strip().splitlines(): - parts = [p.strip(' "') for p in line.split('","')] - if parts: - proc_name = parts[0] - for target in targets: - if proc_name.lower() == target.lower(): - found.append(f"{proc_name} (PID: {parts[1]})") - except Exception: - pass + wanted = {t.lower() for t in targets} + check_path = wanted & {p.lower() for p in OFFICEPLUS_PROCESSES} + my_session = current_session_id() if session_only else None + for proc in list_processes(): + if proc.name.lower() not in wanted: + continue + if my_session is not None and proc.session != my_session: + continue + if proc.name.lower() in check_path: + path = process_path(proc.pid) + if path and not is_officeplus_image(path, OFFICEPLUS_KEYWORD): + continue + found.append(f"{proc.name} (PID: {proc.pid})") return found def scan_services() -> List[str]: found = [] for svc in get_services(): try: - output = subprocess.check_output( - ["sc.exe", "query", svc], + res = subprocess.run( + [system_exe("sc.exe"), "query", svc], creationflags=subprocess.CREATE_NO_WINDOW, - encoding="utf-8", - errors="ignore" + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=COMMAND_TIMEOUT, ) - if "RUNNING" in output or "STOPPED" in output or "PAUSED" in output: + # 看退出码而不是状态文字:0 = 服务存在(含 START_PENDING、STOP_PENDING 等中间状态), + # 1060 = 服务不存在 + if res.returncode == 0: found.append(svc) except Exception: pass @@ -111,8 +120,10 @@ def list_scheduled_tasks() -> List[str]: # 读不到文件夹时退回 schtasks,按控制台实际使用的代码页解码(中文系统是 GBK) output = subprocess.check_output( - ["schtasks", "/Query", "/FO", "CSV", "/NH"], + [system_exe("schtasks.exe"), "/Query", "/FO", "CSV", "/NH"], creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + timeout=COMMAND_TIMEOUT, encoding=f"cp{ctypes.windll.kernel32.GetOEMCP()}", errors="replace", ) @@ -153,5 +164,5 @@ def run_scan() -> ScanReport: registry_keys=_safe(scan_registry), directories=_safe(scan_directories), scheduled_tasks=_safe(scan_scheduled_tasks), - office_apps=_safe(scan_processes, OFFICE_APP_PROCESSES), + office_apps=_safe(scan_processes, OFFICE_APP_PROCESSES, True), ) diff --git a/src/remove_officeplus/winutil.py b/src/remove_officeplus/winutil.py new file mode 100644 index 0000000..d00f3d7 --- /dev/null +++ b/src/remove_officeplus/winutil.py @@ -0,0 +1,119 @@ +"""调用系统命令、查进程、检查目录时共用的 Windows 小工具。 + +- system_exe:系统命令(reg、taskkill、sc、net、schtasks、tasklist)一律用 System32 下的绝对路径。 + 只写文件名时,Windows 会先在程序自己所在的目录(比如「下载」)里找同名 exe, + 有人在那里放一个假的 taskkill.exe,就会被以管理员身份执行。 +- list_processes:列出进程的名字、PID、会话号和程序路径,用来只关当前会话的 Office、 + 只结束程序路径确实属于 OfficePLUS 的进程。 +- is_reparse_point:备份和日志写进用户自己的「文档」,写之前确认文件夹不是联接/符号链接, + 避免被引到系统目录里去。 +""" +import csv +import ctypes +import os +import stat +import subprocess +from typing import List, NamedTuple, Optional + +# 单个系统命令最长等多久(秒)。超时按失败处理并记进日志,不让命令行或 GUI 卡死 +COMMAND_TIMEOUT = 60 + +_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + +def system_dir() -> str: + """真正的系统目录(通常是 C:\\Windows\\System32)。优先问系统,不信环境变量。""" + try: + buf = ctypes.create_unicode_buffer(260) + if ctypes.windll.kernel32.GetSystemDirectoryW(buf, 260) and buf.value: + return buf.value + except Exception: + pass + return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32") + + +def system_exe(name: str) -> str: + """System32 下系统命令的绝对路径,例如 system_exe("taskkill.exe")。""" + return os.path.join(system_dir(), name) + + +def current_session_id() -> Optional[int]: + """当前进程所在的会话号;取不到返回 None。""" + try: + kernel32 = ctypes.windll.kernel32 + session = ctypes.c_ulong() + if kernel32.ProcessIdToSessionId(kernel32.GetCurrentProcessId(), ctypes.byref(session)): + return session.value + except Exception: + pass + return None + + +def process_path(pid: int) -> Optional[str]: + """进程的程序完整路径;没权限或进程已退出时返回 None。""" + try: + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return None + try: + size = ctypes.c_ulong(32768) + buf = ctypes.create_unicode_buffer(size.value) + if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)): + return buf.value + finally: + kernel32.CloseHandle(handle) + except Exception: + pass + return None + + +class ProcessInfo(NamedTuple): + name: str + pid: int + session: Optional[int] + + +def parse_tasklist_csv(output: str) -> List[ProcessInfo]: + """解析 `tasklist /FO CSV /NH` 的输出:映像名称, PID, 会话名, 会话#, 内存。 + 某一行格式不对只跳过这一行,不影响后面的进程。""" + procs = [] + for row in csv.reader(output.splitlines()): + try: + if len(row) < 2: + continue + session = int(row[3]) if len(row) > 3 and row[3].strip().isdigit() else None + procs.append(ProcessInfo(row[0].strip(), int(row[1]), session)) + except (ValueError, IndexError): + continue + return procs + + +def list_processes() -> List[ProcessInfo]: + output = subprocess.check_output( + [system_exe("tasklist.exe"), "/FO", "CSV", "/NH"], + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + encoding="utf-8", + errors="ignore", + timeout=COMMAND_TIMEOUT, + ) + return parse_tasklist_csv(output) + + +def is_officeplus_image(path: Optional[str], keyword: str) -> bool: + """程序所在的文件夹路径里含 keyword(不区分大小写),才算 OfficePLUS 自己的进程。 + DeepLink.exe、OfficePLUS.exe 这类名字很常见,只看进程名会误杀别的软件。""" + return bool(path) and keyword.lower() in os.path.dirname(path).lower() + + +def is_reparse_point(path: str) -> bool: + """path 本身是联接、符号链接等重解析点时返回 True;不存在返回 False。""" + try: + st = os.lstat(path) + except OSError: + return False + if stat.S_ISLNK(st.st_mode): + return True + attrs = getattr(st, "st_file_attributes", 0) + return bool(attrs & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) diff --git a/tests/e2e/fake_residue.py b/tests/e2e/fake_residue.py index 7fa2f33..dd9eabb 100644 --- a/tests/e2e/fake_residue.py +++ b/tests/e2e/fake_residue.py @@ -96,8 +96,10 @@ def process_running(name): def start_fake(name, minutes=60): - """把 ping.exe 复制成指定名字并后台运行,模拟一个进程。""" - fake_exe = os.path.join(tempfile.mkdtemp(), name) + """把 ping.exe 复制成指定名字并后台运行,模拟一个进程。 + 清理时会核对 OfficePLUS 进程的程序路径,所以放在名字含 OfficePLUS 的临时文件夹里, + 和真实安装(C:\\Program Files\\Microsoft OfficePLUS\\...)一样。""" + fake_exe = os.path.join(tempfile.mkdtemp(prefix="OfficePLUS_e2e_"), name) with open(r"C:\Windows\System32\PING.EXE", "rb") as src, open(fake_exe, "wb") as dst: dst.write(src.read()) subprocess.Popen([fake_exe, "-n", str(minutes * 60), "127.0.0.1"], From 311aa16d79a58e0c9cdd50b7455f23f0e6ced969 Mon Sep 17 00:00:00 2001 From: HenryYannis <247510158+HenryYannis@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:52:54 +0000 Subject: [PATCH 2/5] =?UTF-8?q?PowerShell=20=E7=89=88=EF=BC=9A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=91=BD=E4=BB=A4=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E3=80=81-LiteralPath=E3=80=81=E5=8F=AA=E5=85=B3=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E4=BC=9A=E8=AF=9D=E7=9A=84=20Office=E3=80=81=E8=81=94?= =?UTF-8?q?=E6=8E=A5=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 遇到的问题:#1(reg.exe/sc.exe/attrib 不带路径)、#3(Stop-Process 不分会话)、 #6(Test-Path/Remove-Item 用 -Path,会按通配符解释)、#8(OfficePLUS 进程只按名字匹配)、 #14(32 位 PowerShell 下 ProgramFiles 指向 x86)、#16(备份和日志写进用户可控的文件夹没检查联接), 以及复核认为基本不属实的 #2:顺手加一个便宜的防护。 我的改动: - $Sys32 = [Environment]::SystemDirectory,reg.exe、sc.exe、attrib.exe 都用绝对路径; attrib 不再经过 cmd.exe /c,路径里的 % 也不会被展开。 - 注册表和目录的 Test-Path / Remove-Item / Get-Content 一律改成 -LiteralPath。 - 保存提醒和结束进程只针对当前会话的 Office;OfficePLUS 进程要求程序文件夹含 OfficePLUS, 读不到路径就跳过(扫描时读不到路径仍然报告)。 - $ProgramFiles64 优先取 ProgramW6432;sync_rules.py 同步生成这个变量。 - 日志文件夹、备份文件夹是重解析点时拒绝写入(备份按写入失败处理,不删注册表)。 - 残留目录本身是联接/符号链接时,只删链接本身,不 attrib、不递归进去(#2 的低成本防护)。 - 文件保持 UTF-8 带 BOM。 怎么测试的:pwsh 7.4(Linux)解析无错误;PSScriptAnalyzer 警告与改动前完全相同(无新增); 在 pwsh 里单独测了 Test-ReparsePoint、Test-OfficePlusProcess 和删除符号链接不影响目标目录。 没有在 Windows / PowerShell 5.1 上实测,需要 E2E 验证。 --- scripts/Remove-OfficePlus.ps1 | 83 +++++++++++++++++++++++++++-------- scripts/sync_rules.py | 2 +- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/scripts/Remove-OfficePlus.ps1 b/scripts/Remove-OfficePlus.ps1 index bd5d42c..a6bef12 100644 --- a/scripts/Remove-OfficePlus.ps1 +++ b/scripts/Remove-OfficePlus.ps1 @@ -137,6 +137,13 @@ public static string Query(int sessionId, int infoClass) { } $TargetUser = Get-TargetUser + +# 系统命令一律用 System32 下的绝对路径,不靠 PATH 和当前目录查找同名程序 +$Sys32 = [Environment]::SystemDirectory +# 32 位 PowerShell 在 64 位系统上,ProgramFiles 指向 Program Files (x86);ProgramW6432 才是 64 位的 +$ProgramFiles64 = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } +# 当前会话号:清理时只关当前登录用户的 Office,不碰同一台电脑上其他用户的 +$MySessionId = (Get-Process -Id $PID).SessionId if ($TargetUser) { $UserRegRoot = "Registry::HKEY_USERS\$($TargetUser.Sid)" $UserLocalAppData = $TargetUser.LocalAppData @@ -202,7 +209,7 @@ $TargetRegistryKeys = @( # 系统目录取环境变量(系统不一定装在 C 盘);哪个位置取不到就跳过,不能拼成盘符根目录 $TargetDirectories = @( - if ($env:ProgramFiles) { Join-Path $env:ProgramFiles "Microsoft OfficePLUS" } + if ($ProgramFiles64) { Join-Path $ProgramFiles64 "Microsoft OfficePLUS" } if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} "Microsoft OfficePLUS" } if ($env:ProgramData) { Join-Path $env:ProgramData "Microsoft OfficePLUS" } if ($UserLocalAppData) { Join-Path $UserLocalAppData "OfficePLUS" } @@ -268,6 +275,22 @@ function Join-Unique { } } +# OfficePLUS 自己的进程(DeepLink.exe 这类名字很常见):程序路径读得到、却不在 OfficePLUS 的文件夹里,就不算 +function Test-OfficePlusProcess { + param($Process) + $path = $null + try { $path = $Process.Path } catch { $path = $null } + if (-not $path) { return $null } # 读不到路径 + return ((Split-Path -Parent $path) -like "*$AddinKeyword*") +} + +# 联接、符号链接等重解析点 +function Test-ReparsePoint { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + return [bool]($item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) +} + $TargetRegistryKeys = @(Join-Unique (@($TargetRegistryKeys) + @(Find-OfficePlusAddins))) $TargetServices = @(Join-Unique (@($TargetServices) + @(Find-OfficePlusServices))) @@ -321,7 +344,8 @@ function Invoke-OfficePlusScan { # 1. 检测运行进程 Write-Host "`n[1] 进程检测:" -ForegroundColor Cyan - $runningProcs = Get-Process -Name $OfficePlusProcesses -ErrorAction SilentlyContinue + # 程序路径读不到时(例如未提权时的系统服务进程)照样报告,宁可多报 + $runningProcs = @(Get-Process -Name $OfficePlusProcesses -ErrorAction SilentlyContinue | Where-Object { (Test-OfficePlusProcess $_) -ne $false }) if ($runningProcs) { foreach ($p in $runningProcs) { Write-Danger "发现运行中进程: $($p.Name) (PID: $($p.Id))" @@ -347,7 +371,7 @@ function Invoke-OfficePlusScan { Write-Host "`n[3] Office 加载项与注册表残留检测:" -ForegroundColor Cyan $foundRegCount = 0 foreach ($reg in $TargetRegistryKeys) { - if (Test-Path $reg) { + if (Test-Path -LiteralPath $reg) { Write-Danger "发现注册表项: $reg" $foundRegCount++ $foundItems++ @@ -373,7 +397,7 @@ function Invoke-OfficePlusScan { Write-Host "`n[5] 文件目录与缓存残留检测:" -ForegroundColor Cyan $foundDirCount = 0 foreach ($dir in $TargetDirectories) { - if (Test-Path $dir) { + if (Test-Path -LiteralPath $dir) { Write-Danger "发现残留目录: $dir" $foundDirCount++ $foundItems++ @@ -412,7 +436,7 @@ function Invoke-OfficePlusNuke { # 提醒用户先保存正在编辑的 Office 文档(-Force 时跳过) if (-not $Force) { - $openOffice = Get-Process -Name $OfficeAppProcesses -ErrorAction SilentlyContinue + $openOffice = @(Get-Process -Name $OfficeAppProcesses -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq $MySessionId }) if ($openOffice) { $names = ($openOffice | Select-Object -ExpandProperty Name -Unique) -join "、" Write-Warn "检测到以下 Office 程序正在运行:$names" @@ -430,6 +454,8 @@ function Invoke-OfficePlusNuke { $script:LogFile = $null try { $logDir = Join-Path $UserDocuments "Remove-OfficePlus 备份" + # 管理员身份往用户可控的「文档」里写:文件夹是联接/符号链接时不写,免得被引到系统目录里 + if (Test-ReparsePoint $logDir) { throw "日志文件夹是联接或符号链接,拒绝写入: $logDir" } New-Item -ItemType Directory -Path $logDir -Force -ErrorAction Stop | Out-Null $stem = "清理日志_" + (Get-Date).ToString("yyyyMMdd_HHmmss") $logPath = Join-Path $logDir "$stem.txt" @@ -450,7 +476,7 @@ function Invoke-OfficePlusNuke { $script:BackupFile = $null $backupFailed = @() if (-not $NoBackup) { - $existingRegs = @($TargetRegistryKeys | Where-Object { Test-Path $_ }) + $existingRegs = @($TargetRegistryKeys | Where-Object { Test-Path -LiteralPath $_ }) if ($existingRegs.Count -gt 0) { $backupDir = Join-Path $UserDocuments "Remove-OfficePlus 备份" $tempDir = Join-Path $env:TEMP ("OfficePLUS_backup_" + [guid]::NewGuid()) @@ -462,19 +488,21 @@ function Invoke-OfficePlusNuke { foreach ($reg in $existingRegs) { $rawRegPath = $reg -replace '^HKLM:\\', 'HKLM\' -replace '^HKCU:\\', 'HKCU\' -replace '^Registry::HKEY_USERS\\', 'HKU\' $part = Join-Path $tempDir "$i.reg" - $null = & reg.exe export "$rawRegPath" "$part" /y /reg:64 2>&1 - if ($LASTEXITCODE -eq 0 -and (Test-Path $part)) { + $null = & "$Sys32\reg.exe" export "$rawRegPath" "$part" /y /reg:64 2>&1 + if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $part)) { $merged.Add("") - $merged.AddRange([string[]](Get-Content -Path $part -Encoding Unicode | Where-Object { $_ -ne $header })) + $merged.AddRange([string[]](Get-Content -LiteralPath $part -Encoding Unicode | Where-Object { $_ -ne $header })) } else { $backupFailed += $reg Write-Warn "注册表项备份失败,为安全起见本次不删除它: $reg" } $i++ } - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue if ($merged.Count -gt 1) { try { + # 备份文件夹是联接/符号链接时拒绝写入,宁可这次不清注册表 + if (Test-ReparsePoint $backupDir) { throw "备份文件夹是联接或符号链接,拒绝写入: $backupDir" } New-Item -ItemType Directory -Path $backupDir -Force -ErrorAction Stop | Out-Null # 同一秒内多次备份时加 _2、_3 后缀,绝不覆盖以前的备份 $stem = "OfficePLUS_注册表备份_" + (Get-Date).ToString("yyyyMMdd_HHmmss") @@ -514,11 +542,22 @@ function Invoke-OfficePlusNuke { } # 1. 终止所有占用进程 + # Office 程序只关当前会话(当前用户)的;OfficePLUS 自己的进程(服务进程在会话 0)不分会话, + # 但程序路径必须在 OfficePLUS 的文件夹里,读不到路径也不结束,避免误杀其他软件的同名进程 Write-Step "第 1/5 步: 终止占用进程与 Office 软件..." foreach ($pName in $TargetProcesses) { $procs = Get-Process -Name $pName -ErrorAction SilentlyContinue if ($procs) { foreach ($p in $procs) { + if ($OfficePlusProcesses -contains $pName) { + if (-not (Test-OfficePlusProcess $p)) { + Write-Info "跳过进程 $pName (PID: $($p.Id)):程序路径不属于 OfficePLUS 或无法读取" + continue + } + } elseif ($p.SessionId -ne $MySessionId) { + Write-Info "跳过进程 $pName (PID: $($p.Id)):不在当前会话(其他用户的 Office)" + continue + } Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { Write-Danger "无法结束进程: $pName (PID: $($p.Id))" @@ -536,7 +575,7 @@ function Invoke-OfficePlusNuke { $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue if ($svc) { Stop-Service -Name $svcName -Force -ErrorAction SilentlyContinue - & sc.exe delete "$svcName" | Out-Null + & "$Sys32\sc.exe" delete "$svcName" | Out-Null if ($LASTEXITCODE -eq 0) { Write-Success "已彻底注销系统服务: $svcName" } else { @@ -549,9 +588,9 @@ function Invoke-OfficePlusNuke { Write-Step "第 3/5 步: 移除全局与用户级注册表加载项..." foreach ($reg in $TargetRegistryKeys) { if ($backupFailed -contains $reg) { continue } - if (Test-Path $reg) { - Remove-Item -Path $reg -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable regErr - if (Test-Path $reg) { + if (Test-Path -LiteralPath $reg) { + Remove-Item -LiteralPath $reg -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable regErr + if (Test-Path -LiteralPath $reg) { Write-Danger "删除注册表项失败: $reg" if ($regErr) { Write-Info "原因: $($regErr[0].Exception.Message)" } } else { @@ -580,11 +619,17 @@ function Invoke-OfficePlusNuke { # 5. 彻底删除程序与缓存文件 Write-Step "第 5/5 步: 彻底删除安装目录与所有缓存..." foreach ($dir in $TargetDirectories) { - if (Test-Path $dir) { - # 解除只读/隐藏/系统文件属性锁定 - $null = & cmd.exe /c "attrib -r -s -h `"$dir\*`" /s /d" 2>$null - Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable dirErr - if (Test-Path $dir) { + if (Test-Path -LiteralPath $dir) { + if (Test-ReparsePoint $dir) { + # 目录本身是联接/符号链接:只删链接,不进入、不改它指向的目录 + $dirErr = $null + try { [IO.Directory]::Delete($dir, $false) } catch { $dirErr = @($_) } + } else { + # 解除只读/隐藏/系统文件属性锁定 + $null = & "$Sys32\attrib.exe" -r -s -h "$dir\*" /s /d 2>$null + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable dirErr + } + if (Test-Path -LiteralPath $dir) { Write-Danger "目录未能完全删除(可能有文件正被占用): $dir" foreach ($e in @($dirErr | Select-Object -First 3)) { Write-Info "原因: $($e.Exception.Message)" } } else { diff --git a/scripts/sync_rules.py b/scripts/sync_rules.py index 256fb57..3043993 100644 --- a/scripts/sync_rules.py +++ b/scripts/sync_rules.py @@ -17,7 +17,7 @@ # 目录规则里的位置 → PowerShell 里的变量(用户目录指实际要清理的那个用户) BASES = { - "ProgramFiles": "$env:ProgramFiles", + "ProgramFiles": "$ProgramFiles64", # 脚本里优先取 ProgramW6432,32 位 PowerShell 也能找到 64 位目录 "ProgramFiles(x86)": "${env:ProgramFiles(x86)}", "ProgramData": "$env:ProgramData", "LocalAppData": "$UserLocalAppData", From 7833e759c6f0db1626a9620edb7e1c775fabb9f8 Mon Sep 17 00:00:00 2001 From: HenryYannis <247510158+HenryYannis@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:52:55 +0000 Subject: [PATCH 3/5] =?UTF-8?q?CI=EF=BC=9AActions=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=88=B0=20commit=20SHA=E3=80=81=E5=9B=BA=E5=AE=9A=20pyinstall?= =?UTF-8?q?er=20=E7=89=88=E6=9C=AC=E3=80=81=E9=BB=98=E8=AE=A4=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 遇到的问题(#17 部分属实):release.yml 和 ci.yml 里 pip install pyinstaller 没固定版本, actions/checkout、setup-python、action-gh-release 用的是会移动的标签;ci.yml、e2e.yml 没有声明权限。 我的改动: - 三个 Action 固定到当前大版本标签指向的完整 commit SHA,后面注释具体版本: checkout v4.4.0、setup-python v5.6.0、action-gh-release v2.6.2; - pyinstaller 固定为 6.22.3(ci.yml 的打包任务和 release.yml 一致); - ci.yml、e2e.yml 顶层加 permissions: contents: read;release.yml 原本就是 contents: write(上传 Release 需要),不变。 怎么测试的:SHA 用 git ls-remote 核对过;工作流本身要在这个 PR 的 CI 里跑过才算验证。 --- .github/workflows/ci.yml | 15 +++++++++------ .github/workflows/e2e.yml | 11 +++++++---- .github/workflows/release.yml | 8 ++++---- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e80c0c..9299d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,14 @@ on: pull_request: branches: [ "main" ] +permissions: + contents: read + jobs: powershell-lint: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run PSScriptAnalyzer shell: pwsh run: | @@ -22,9 +25,9 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies & package @@ -50,14 +53,14 @@ jobs: env: PYTHONUTF8: "1" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: 打包 run: | python -m pip install --upgrade pip - pip install pyinstaller + pip install pyinstaller==6.22.3 pip install -e . python build_exe.py if ($LASTEXITCODE) { exit $LASTEXITCODE } diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7538a1e..29eac46 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,6 +9,9 @@ on: branches: [ "main" ] workflow_dispatch: +permissions: + contents: read + jobs: python-cli: name: Python 版(GUI/exe 用的同一套清理代码) @@ -17,8 +20,8 @@ jobs: env: PYTHONUTF8: "1" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: 安装 @@ -110,8 +113,8 @@ jobs: env: PYTHONUTF8: "1" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: 模拟一键命令(irm | iex)能否正常解析 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09e259e..c8289f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,17 +14,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pyinstaller + pip install pyinstaller==6.22.3 pip install -e . - name: Check tag matches version @@ -40,7 +40,7 @@ jobs: python build_exe.py - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 if: startsWith(github.ref, 'refs/tags/') with: files: | From ddd28599a351d9b947a1757624ee380ab9cb7127 Mon Sep 17 00:00:00 2001 From: HenryYannis <247510158+HenryYannis@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:09:27 +0000 Subject: [PATCH 4/5] =?UTF-8?q?AGENTS.md=EF=BC=9A=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E2=80=9C=E4=B8=8D=E6=94=B9=20gui.py=E2=80=9D=E5=92=8C=E2=80=9C?= =?UTF-8?q?=E6=AF=8F=E4=B8=AA=E9=97=AE=E9=A2=98=E4=B8=80=E4=B8=AA=20PR?= =?UTF-8?q?=E2=80=9D=E4=B8=A4=E6=9D=A1=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这两条不是我对这个项目的要求,删掉。规则 8 是最后一条,删除后其余编号不变; 提交流程保留“CI 和 E2E 全绿后再合并,合并后删除分支,仓库只保留 main”。 仓库里没有其他代理说明文件(CLAUDE.md、.cursor、copilot-instructions 等)需要同步。 --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 566a017..9153dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,11 +31,10 @@ python build_exe.py # 打包 exe 和 SHA256SUMS.txt(需要 p 5. **规则只改 `rules.json`**,然后运行 `sync_rules.py`;最好附上真实安装的注册表导出作为依据。 6. **编码**:`Remove-OfficePlus.ps1` 保持 UTF-8 带 BOM(PowerShell 5.1 才能正确读中文);`Quick-Remove.bat` 保持纯 ASCII + CRLF。 7. **兼容性**:Windows 10/11;PowerShell 5.1 和 7+;Python 3.8+。 -8. **不改 `gui.py`**:图形界面需要的改进放在它调用的核心代码里。 ## 提交流程 -- 每个问题一个分支和一个 PR,CI 和 E2E 全绿后再合并,合并后删除分支,仓库只保留 `main`。 +- CI 和 E2E 全绿后再合并,合并后删除分支,仓库只保留 `main`。 - 提交信息和 PR 描述用第一人称写清楚:遇到的问题、我的改动、怎么测试的。 ## 发布流程 From 534f6b2d817a003c9b79629a214c461af3ce2a63 Mon Sep 17 00:00:00 2001 From: HenryYannis <247510158+HenryYannis@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:09:27 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=E5=9B=BE=E5=BD=A2=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=EF=BC=9A=E6=B8=85=E7=90=86=E7=BB=93=E6=9E=9C=E6=8C=89=E5=A4=8D?= =?UTF-8?q?=E6=A3=80=E5=A6=82=E5=AE=9E=E6=8F=90=E7=A4=BA=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1=E5=8D=A1=E7=89=87?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E6=9D=83=E8=A2=AB=E6=8B=92=E6=97=B6=E7=BB=99?= =?UTF-8?q?=E5=87=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 遇到的问题: - 清理完成后不管实际结果,日志固定写“已终止相关进程…已清理完成”,并弹出「清理完成」, 也不告诉用户注册表备份和清理日志在哪里(exe 用户只用图形界面); - 统计卡片和扫描日志里没有计划任务,但总数算了计划任务,各项加起来对不上; - 直接跑源码时点「提权」被 UAC 拒绝,界面没有任何反应(错误打到了不存在的 stderr)。 我的改动: - 清理后在后台线程里紧接着复检,按清理返回值和复检结果分成“清理完成 / 部分完成 / 清理失败” 三种提示(信息 / 警告 / 错误弹窗),日志按实际数量写,弹窗里附上注册表备份和清理日志路径; 清理或复检出错时也会回到界面、恢复按钮,不当作成功; - 新增「计划任务」卡片(共 5 张),扫描日志列出计划任务; - elevate() 返回 False 时弹出“未获得管理员权限”提示,出错时弹出错误提示。 怎么测试的: - 新增 tests/gui_result_check.py:用假的弹窗和界面对象检查三种结果、备份/日志路径、 复检出错不卡住、计划任务卡片、UAC 被拒提示;没有 tkinter/winreg 的系统也能跑,已加进 CI; - 本地 py_compile、pyflakes、sync_rules.py --check、real_sample_check.py 均通过。 --- .github/workflows/ci.yml | 4 + src/remove_officeplus/gui.py | 117 +++++++++++++++++++++++---- tests/gui_result_check.py | 151 +++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 tests/gui_result_check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9299d9b..f935f6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: run: python tests/real_sample_check.py env: PYTHONUTF8: "1" + - name: 图形界面如实提示清理结果 + run: python tests/gui_result_check.py + env: + PYTHONUTF8: "1" - name: Test Python CLI Syntax & Import run: | python -m remove_officeplus --scan diff --git a/src/remove_officeplus/gui.py b/src/remove_officeplus/gui.py index f6e03df..7af51a7 100644 --- a/src/remove_officeplus/gui.py +++ b/src/remove_officeplus/gui.py @@ -1,4 +1,3 @@ -import os import sys import ctypes import threading @@ -8,6 +7,7 @@ from typing import Optional from . import __version__, DOWNLOAD_URL +from . import cleaner, runlog from .scanner import run_scan, is_admin, ScanReport from .cleaner import run_nuke, elevate @@ -21,6 +21,37 @@ pass +def summarize_nuke(cleaned_total: int, report: Optional[ScanReport], + backup_path: Optional[str], log_path: Optional[str]): + """根据清理返回值和复检结果,决定完成弹窗的级别、标题和正文。 + + 返回 (level, title, message),level 为 "success" / "partial" / "failure"。 + 复检没跑成(report 为 None)时不能当作成功。 + """ + if report is not None and report.is_clean: + level, title = "success", "清理完成" + text = "微软 OfficePLUS 及关联组件已全部清除,复检未发现残留。" + elif report is not None and cleaned_total > 0: + level, title = "partial", "部分完成" + text = (f"已清理 {cleaned_total} 项,但复检仍发现 {report.total_threats} 处残留没能删除。\n" + "建议重启电脑后再运行一次;仍然删不掉的话,欢迎到 GitHub 提交 Issue。") + else: + level, title = "failure", "清理失败" + if report is None: + text = "清理后复检出错,无法确认清理结果。" + else: + text = f"本次没有清理掉任何项,复检仍发现 {report.total_threats} 处残留。" + text += "\n请查看清理日志了解原因。" + lines = [text] + if backup_path: + lines.append(f"\n注册表备份:{backup_path}\n(误删了需要的设置时,双击这个文件即可恢复)") + if log_path: + lines.append(f"\n清理日志:{log_path}") + elif level != "success": + lines.append("\n清理日志没能保存。") + return level, title, "\n".join(lines) + + class ModernGUI(tk.Tk): def __init__(self): super().__init__() @@ -137,15 +168,16 @@ def _create_widgets(self): ) perm_label.pack(side="left", fill="x", expand=True) - # ================= 2. 状态统计卡片区 (4个指标) ================= + # ================= 2. 状态统计卡片区 (5个指标) ================= stats_frame = ttk.Frame(self, style="TFrame") stats_frame.pack(fill="x", padx=16, pady=4) - stats_frame.columnconfigure((0, 1, 2, 3), weight=1, uniform="col") + stats_frame.columnconfigure((0, 1, 2, 3, 4), weight=1, uniform="col") self.card_proc_val = self._create_stat_card(stats_frame, 0, "运行进程", "0") self.card_svc_val = self._create_stat_card(stats_frame, 1, "常驻服务", "0") self.card_reg_val = self._create_stat_card(stats_frame, 2, "注册表项", "0") - self.card_dir_val = self._create_stat_card(stats_frame, 3, "残留目录", "0") + self.card_task_val = self._create_stat_card(stats_frame, 3, "计划任务", "0") + self.card_dir_val = self._create_stat_card(stats_frame, 4, "残留目录", "0") # ================= 3. 核心控制按钮与状态区 (整体居中对称布局) ================= control_container = ttk.Frame(self, style="TFrame") @@ -271,7 +303,18 @@ def log(self, message: str, tag: str = "info"): self.log_text.see(tk.END) def _do_elevate(self): - elevate() + # 提权成功时 elevate() 会重新启动并退出当前进程;走到下面说明没能提权 + try: + ok = elevate() + except Exception as e: + messagebox.showerror("提权失败", f"以管理员身份重新启动时出错:{e!r}") + return + if not ok: + messagebox.showwarning( + "未获得管理员权限", + "没有获得管理员权限(可能在 UAC 提示中选择了「否」)。\n\n" + "当前仍以普通权限运行,可以扫描,但无法彻底清理服务与系统级注册表。" + ) def set_buttons_state(self, enabled: bool): state = tk.NORMAL if enabled else tk.DISABLED @@ -308,6 +351,10 @@ def _on_scan_finished(self, report: ScanReport): text=str(len(report.registry_keys)), fg="#EF4444" if report.registry_keys else "#10B981" ) + self.card_task_val.config( + text=str(len(report.scheduled_tasks)), + fg="#EF4444" if report.scheduled_tasks else "#10B981" + ) self.card_dir_val.config( text=str(len(report.directories)), fg="#EF4444" if report.directories else "#10B981" @@ -332,6 +379,12 @@ def _on_scan_finished(self, report: ScanReport): else: self.log(" [+] 未发现任何 OfficePLUS 相关注册表项。", "success") + if report.scheduled_tasks: + for t in report.scheduled_tasks: + self.log(f" [-] 发现计划任务: {t}", "danger") + else: + self.log(" [+] 未发现 OfficePLUS 相关计划任务。", "success") + if report.directories: for d in report.directories: self.log(f" [-] 发现残留目录: {d}", "danger") @@ -362,22 +415,54 @@ def start_nuke(self): self.log(">>> 开始清理……", "warn") def _worker(): - procs, svcs, regs, tasks, dirs = run_nuke() - self.after(0, lambda: self._on_nuke_finished(procs, svcs, regs, tasks, dirs)) + # 清理后紧接着复检,按复检结果判断成功与否;任何一步出错都要回到界面,不能卡住 + procs, svcs, regs, tasks, dirs = [], [], [], [], [] + report = None + try: + procs, svcs, regs, tasks, dirs = run_nuke() + report = run_scan() + except Exception as e: + print(f"[!] 清理或复检出错:{e!r}", file=sys.stderr) + self.after(0, lambda: self._on_nuke_finished(procs, svcs, regs, tasks, dirs, report)) threading.Thread(target=_worker, daemon=True).start() - def _on_nuke_finished(self, procs, svcs, regs, tasks, dirs): - self.log(" [+] 已终止相关进程", "success") - self.log(" [+] 已注销并清理相关服务", "success") - self.log(f" [+] 已清理 {len(regs)} 项注册表加载项", "success") - self.log(f" [+] 已清理 {len(tasks)} 项计划任务", "success") - self.log(f" [+] 已删除 {len(dirs)} 处程序与缓存目录", "success") - self.log("[完成] 微软 OfficePLUS 及关联组件已清理完成。", "success") + def _on_nuke_finished(self, procs, svcs, regs, tasks, dirs, report: Optional[ScanReport] = None): + cleaned = [ + (procs, "个相关进程", "已结束"), + (svcs, "个后台服务", "已删除"), + (regs, "项注册表加载项", "已清理"), + (tasks, "项计划任务", "已清理"), + (dirs, "处程序与缓存目录", "已删除"), + ] + for items, unit, verb in cleaned: + self.log(f" [+] {verb} {len(items)} {unit}", "success" if items else "muted") + cleaned_total = sum(len(items) for items, _, _ in cleaned) + + backup_path = cleaner.LAST_BACKUP_PATH + log_path = runlog.LAST_LOG_PATH + level, title, message = summarize_nuke(cleaned_total, report, backup_path, log_path) + if backup_path: + self.log(f" [*] 注册表备份: {backup_path}", "info") + if log_path: + self.log(f" [*] 清理日志: {log_path}", "info") + tag = {"success": "success", "partial": "warn", "failure": "danger"}[level] + self.log(f"[{title}] " + message.split("\n", 1)[0], tag) self.log("==================================================\n", "muted") - messagebox.showinfo("清理完成", "微软 OfficePLUS 及关联组件已清理完成。\n现在系统将自动进行一次复检。") - self.start_scan() + if report is not None: + self.log(">>> 清理后复检结果:", "info") + self._on_scan_finished(report) + else: + self.set_buttons_state(True) + self.status_label.config(text="清理后复检出错,请重新排查扫描") + + if level == "success": + messagebox.showinfo(title, message) + elif level == "partial": + messagebox.showwarning(title, message) + else: + messagebox.showerror(title, message) def launch_gui(): diff --git a/tests/gui_result_check.py b/tests/gui_result_check.py new file mode 100644 index 0000000..3054587 --- /dev/null +++ b/tests/gui_result_check.py @@ -0,0 +1,151 @@ +"""检查图形界面的清理结果提示是否如实(不弹窗、不碰系统,任何系统都能跑)。 + +- 清理完成弹窗按复检结果区分:全部清除 / 部分完成 / 失败,并显示备份和日志路径; +- 统计卡片包含计划任务,各项加起来等于总数; +- 点「提权」被 UAC 拒绝时有提示,而不是什么都不发生。 +没有 tkinter 或 winreg 的环境(如 Linux)会用假模块代替,只测逻辑。 +""" +import importlib +import importlib.util +import os +import sys +import types + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "src")) + +if importlib.util.find_spec("winreg") is None: + fake = types.ModuleType("winreg") + for name in ("HKEY_LOCAL_MACHINE", "HKEY_CURRENT_USER", "HKEY_USERS", "KEY_READ", + "KEY_WOW64_64KEY", "KEY_ALL_ACCESS", "KEY_WRITE"): + setattr(fake, name, 0) + sys.modules["winreg"] = fake +try: + # tkinter 本身能找到,但缺 Tk 库时导入 _tkinter 才会报错 + importlib.import_module("_tkinter") + tk_ok = True +except ImportError: + tk_ok = False +if not tk_ok: + tk = types.ModuleType("tkinter") + tk.Tk = object + tk.NORMAL, tk.DISABLED, tk.END, tk.WORD = "normal", "disabled", "end", "word" + for sub in ("ttk", "messagebox", "scrolledtext"): + m = types.ModuleType("tkinter." + sub) + setattr(tk, sub, m) + sys.modules["tkinter." + sub] = m + sys.modules["tkinter"] = tk + +from remove_officeplus import gui # noqa: E402 +from remove_officeplus.scanner import ScanReport # noqa: E402 + +FAILS = [] + + +def check(cond, msg): + print(("[OK] " if cond else "[FAIL] ") + msg) + if not cond: + FAILS.append(msg) + + +class FakeMessagebox: + def __init__(self): + self.calls = [] + + def __getattr__(self, kind): + if not kind.startswith("show"): + raise AttributeError(kind) + return lambda title, message: self.calls.append((kind, title, message)) + + +class FakeLabel: + def __init__(self): + self.text = None + + def config(self, **kw): + self.text = kw.get("text", self.text) + + +class FakeApp: + """只带 _on_nuke_finished 用到的属性,不创建窗口。""" + + def __init__(self): + self.lines = [] + self.rescanned = None + self.status_label = FakeLabel() + self.buttons = None + + def log(self, message, tag="info"): + self.lines.append((tag, message)) + + def _on_scan_finished(self, report): + self.rescanned = report + + def set_buttons_state(self, enabled): + self.buttons = enabled + + +def run_finish(nuke_result, report, backup="C:\\backup\\b.reg", log="C:\\backup\\log.txt"): + box = FakeMessagebox() + gui.messagebox = box + gui.cleaner.LAST_BACKUP_PATH = backup + gui.runlog.LAST_LOG_PATH = log + app = FakeApp() + gui.ModernGUI._on_nuke_finished(app, *nuke_result, report) + return app, box.calls + + +def main(): + clean = ScanReport(is_admin=True) + left = ScanReport(is_admin=True, registry_keys=["HKLM\\x"], scheduled_tasks=["\\OfficePLUS"]) + + app, calls = run_finish((["a.exe"], ["svc"], ["HKLM\\k"], ["\\t"], ["C:\\d"]), clean) + check(calls and calls[0][0] == "showinfo" and calls[0][1] == "清理完成", "复检干净时弹出「清理完成」") + check(calls and "b.reg" in calls[0][2] and "log.txt" in calls[0][2], "成功弹窗里有备份和日志路径") + check(app.rescanned is clean, "复检结果显示在卡片和日志里") + + app, calls = run_finish(([], [], ["HKLM\\k"], [], []), left) + check(calls and calls[0][0] == "showwarning" and calls[0][1] == "部分完成", "删掉一部分、仍有残留时提示「部分完成」") + check(calls and "2 处残留" in calls[0][2], "部分完成时写明剩余残留数") + + app, calls = run_finish(([], [], [], [], []), left) + check(calls and calls[0][0] == "showerror" and calls[0][1] == "清理失败", "什么都没删掉时提示「清理失败」") + check(calls and "log.txt" in calls[0][2], "失败弹窗里有日志路径") + check(not any("已清理完成" in m for _, m in app.lines), "失败时日志里没有谎报“已清理完成”") + + app, calls = run_finish((["a.exe"], [], [], [], []), None, backup=None, log=None) + check(calls and calls[0][0] == "showerror", "复检出错时不当作成功") + check(app.buttons is True, "复检出错时按钮恢复可用,界面不卡住") + + # 统计卡片:总数等于各项之和(含计划任务) + r = ScanReport(is_admin=True, processes=["p"], services=["s"], registry_keys=["r"], + directories=["d"], scheduled_tasks=["t1", "t2"]) + src = open(gui.__file__, encoding="utf-8").read() + check("card_task_val" in src and "report.scheduled_tasks" in src, "界面有计划任务卡片") + parts = len(r.processes) + len(r.services) + len(r.registry_keys) + len(r.scheduled_tasks) + len(r.directories) + check(parts == r.total_threats, "五张卡片加起来等于总数") + + # 提权被拒绝时要有提示 + box = FakeMessagebox() + gui.messagebox = box + gui.elevate = lambda: False + gui.ModernGUI._do_elevate(object()) + check(box.calls and box.calls[0][0] == "showwarning" and "UAC" in box.calls[0][2], "UAC 被拒绝时弹出提示") + + def broken(): + raise OSError("ShellExecute 失败") + box = FakeMessagebox() + gui.messagebox = box + gui.elevate = broken + gui.ModernGUI._do_elevate(object()) + check(box.calls and box.calls[0][0] == "showerror", "提权出错时弹出错误提示") + + if FAILS: + print(f"\n{len(FAILS)} 项检查失败") + return 1 + print("\n全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main())