Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand All @@ -40,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
Expand All @@ -50,14 +57,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 }
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
branches: [ "main" ]
workflow_dispatch:

permissions:
contents: read

jobs:
python-cli:
name: Python 版(GUI/exe 用的同一套清理代码)
Expand All @@ -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: 安装
Expand Down Expand Up @@ -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)能否正常解析
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 描述用第一人称写清楚:遇到的问题、我的改动、怎么测试的。

## 发布流程
Expand Down
83 changes: 64 additions & 19 deletions scripts/Remove-OfficePlus.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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))"
Expand All @@ -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++
Expand All @@ -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++
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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())
Expand All @@ -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")
Expand Down Expand Up @@ -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))"
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion scripts/sync_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 19 additions & 8 deletions src/remove_officeplus/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading