Initial commit
This commit is contained in:
+494
@@ -0,0 +1,494 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# Mac bundle stuff
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# Repository-local SDK and release artifacts
|
||||
.tools/
|
||||
artifacts/
|
||||
|
||||
# The macOS *.app rule above also matches this WPF project on Windows.
|
||||
!src/WindowsToolbox.App/
|
||||
!src/WindowsToolbox.App/*.cs
|
||||
!src/WindowsToolbox.App/*.xaml
|
||||
!src/WindowsToolbox.App/*.csproj
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<Deterministic>true</Deterministic>
|
||||
<ApplicationIcon Condition="Exists('$(MSBuildThisFileDirectory)assets\WindowsToolbox.ico')">$(MSBuildThisFileDirectory)assets\WindowsToolbox.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,73 @@
|
||||
# Windows 工具箱
|
||||
|
||||
面向 Windows 10/11 的中文 WPF 系统工具。主程序以普通权限运行;修改系统设置时才启动独立管理员执行器。所有可恢复功能会在第一次修改前保存基线快照,重复执行不会覆盖原始状态。
|
||||
|
||||
## 已实现功能
|
||||
|
||||
- Windows 11 经典右键菜单:启用、恢复;修改后自动重启 Explorer,在 Build 26200 上立即生效。
|
||||
- 快捷方式箭头:隐藏、恢复,并生成本地透明图标资源。
|
||||
- Windows 更新:暂停 1—35 天、每日自动续期、强力关闭、每日关闭守护、恢复原始状态。
|
||||
- Windows 安全:检测防篡改保护、关闭可控制的 Defender 防护、可选关闭防火墙、恢复原始设置。
|
||||
- 自动登录:恢复 `netplwiz` 中可能隐藏的密码复选框并打开 Windows 自带账户控制;工具不读取密码。
|
||||
- 桌面归档:使用 Known Folder API 识别真实桌面,按 `yyyy-MM-dd` 归档普通文件和普通文件夹;排除快捷方式、系统项及重解析目录,自动处理同名文件/文件夹,支持撤销和每月计划任务。
|
||||
- KMS:内置微软公开的 Windows 10/11 GVLK,使用用户填写的本地 KMS 地址,连接成功后才安装密钥,支持恢复原 KMS 主机。
|
||||
- 系统状态、逐步执行结果及最近 100 次操作历史。
|
||||
- 多尺寸应用图标,同时用于窗口标题栏、任务栏和 EXE 文件。
|
||||
|
||||
启动时只运行一个有 5 秒硬超时的可选状态查询进程,主窗口不会等待它才显示;普通功能完成后只刷新注册表基础状态,不再重复等待 Defender 与许可证查询。
|
||||
|
||||
## 运行
|
||||
|
||||
开发构建输出位于:
|
||||
|
||||
```text
|
||||
src\WindowsToolbox.App\bin\Debug\net8.0-windows\WindowsToolbox.App.exe
|
||||
```
|
||||
|
||||
发布目录生成后运行:
|
||||
|
||||
```text
|
||||
artifacts\WindowsToolbox\WindowsToolbox.App.exe
|
||||
```
|
||||
|
||||
运行环境需要 Windows 10/11 x64 和 .NET 8 Desktop Runtime。涉及系统修改的按钮会出现 UAC 提示。
|
||||
|
||||
## 构建
|
||||
|
||||
仓库已经配置本地 .NET 8 SDK 位置。执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-release.ps1
|
||||
```
|
||||
|
||||
生成便携发布目录:
|
||||
|
||||
```text
|
||||
artifacts\WindowsToolbox\
|
||||
```
|
||||
|
||||
项目没有第三方 NuGet 运行依赖。
|
||||
|
||||
## 数据位置
|
||||
|
||||
```text
|
||||
%LOCALAPPDATA%\WindowsToolbox\
|
||||
├─ ArchiveManifests\ 桌面归档恢复清单
|
||||
├─ Logs\ 操作历史
|
||||
├─ Pending\ 临时提权命令及结果
|
||||
└─ Profiles\ 桌面归档配置
|
||||
|
||||
%PROGRAMDATA%\WindowsToolbox\
|
||||
├─ Assets\ 透明快捷方式图标
|
||||
└─ Snapshots\ 系统设置基线快照
|
||||
```
|
||||
|
||||
## 重要边界
|
||||
|
||||
- “强力关闭更新”会关闭可控制的策略、服务和任务,并每日重新检查;Windows 就地修复或重大升级后仍可能恢复受保护组件。
|
||||
- 工具不会绕过 Defender 防篡改保护。关闭 Defender 前必须由用户在 Windows 安全中心手动关闭防篡改保护。
|
||||
- 家庭版不支持 KMS 客户端激活。Windows 客户端的正规 KMS 服务还需要满足微软规定的客户端数量门槛。
|
||||
- KMS 恢复功能可以恢复主机和端口,但 Windows 不提供旧完整产品密钥,无法自动恢复未知旧密钥。
|
||||
- 跨磁盘文件移动不是文件系统原子操作;移动失败会保留在恢复清单和操作日志中。
|
||||
|
||||
更多内部设计见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) 和 [docs/OPERATIONS.md](docs/OPERATIONS.md)。
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E54A5670-8E0E-463E-8FBF-430054BF2736}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.Core", "src\WindowsToolbox.Core\WindowsToolbox.Core.csproj", "{404532FF-0F8C-4A80-A182-3FEBAB789DC3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.Infrastructure", "src\WindowsToolbox.Infrastructure\WindowsToolbox.Infrastructure.csproj", "{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.App", "src\WindowsToolbox.App\WindowsToolbox.App.csproj", "{44AEEFE7-C6CA-482D-BDDF-54F5CF570295}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.ElevatedHost", "src\WindowsToolbox.ElevatedHost\WindowsToolbox.ElevatedHost.csproj", "{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.Cli", "src\WindowsToolbox.Cli\WindowsToolbox.Cli.csproj", "{BC9B81D5-0862-4465-9357-738BB0B2E4E0}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A98D294C-F010-49CE-968F-A85E2F921692}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsToolbox.Verification", "tests\WindowsToolbox.Verification\WindowsToolbox.Verification.csproj", "{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{404532FF-0F8C-4A80-A182-3FEBAB789DC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{404532FF-0F8C-4A80-A182-3FEBAB789DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{404532FF-0F8C-4A80-A182-3FEBAB789DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{404532FF-0F8C-4A80-A182-3FEBAB789DC3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{44AEEFE7-C6CA-482D-BDDF-54F5CF570295}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{44AEEFE7-C6CA-482D-BDDF-54F5CF570295}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{44AEEFE7-C6CA-482D-BDDF-54F5CF570295}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{44AEEFE7-C6CA-482D-BDDF-54F5CF570295}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC9B81D5-0862-4465-9357-738BB0B2E4E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC9B81D5-0862-4465-9357-738BB0B2E4E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC9B81D5-0862-4465-9357-738BB0B2E4E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC9B81D5-0862-4465-9357-738BB0B2E4E0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{404532FF-0F8C-4A80-A182-3FEBAB789DC3} = {E54A5670-8E0E-463E-8FBF-430054BF2736}
|
||||
{32D8C232-E015-4DC5-8DF6-D9B6C70D2BC4} = {E54A5670-8E0E-463E-8FBF-430054BF2736}
|
||||
{44AEEFE7-C6CA-482D-BDDF-54F5CF570295} = {E54A5670-8E0E-463E-8FBF-430054BF2736}
|
||||
{61D6B7B9-FB16-4D3C-B0FA-1EE6B5124E22} = {E54A5670-8E0E-463E-8FBF-430054BF2736}
|
||||
{BC9B81D5-0862-4465-9357-738BB0B2E4E0} = {E54A5670-8E0E-463E-8FBF-430054BF2736}
|
||||
{8CCFF617-CDF1-4EFD-812E-AE92CD75F8CA} = {A98D294C-F010-49CE-968F-A85E2F921692}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="blue" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="54" fill="url(#blue)"/>
|
||||
<path d="M44 66h34l27 91 23-59h1l23 59 27-91h33l-38 126h-35l-11-31-12 31H82L44 66z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1,93 @@
|
||||
[
|
||||
{
|
||||
"editionId": "Professional",
|
||||
"displayName": "Windows 10/11 专业版",
|
||||
"productKey": "W269N-WFGWX-YVC9B-4J6C9-T83GX",
|
||||
"appliesTo": "Windows 10/11 Pro"
|
||||
},
|
||||
{
|
||||
"editionId": "ProfessionalN",
|
||||
"displayName": "Windows 10/11 专业版 N",
|
||||
"productKey": "MH37W-N47XK-V7XM9-C7227-GCQG9",
|
||||
"appliesTo": "Windows 10/11 Pro N"
|
||||
},
|
||||
{
|
||||
"editionId": "ProfessionalWorkstation",
|
||||
"displayName": "Windows 10/11 专业工作站版",
|
||||
"productKey": "NRG8B-VKK3Q-CXVCJ-9G2XF-6Q84J",
|
||||
"appliesTo": "Windows 10/11 Pro for Workstations"
|
||||
},
|
||||
{
|
||||
"editionId": "ProfessionalWorkstationN",
|
||||
"displayName": "Windows 10/11 专业工作站版 N",
|
||||
"productKey": "9FNHH-K3HBT-3W4TD-6383H-6XYWF",
|
||||
"appliesTo": "Windows 10/11 Pro for Workstations N"
|
||||
},
|
||||
{
|
||||
"editionId": "ProfessionalEducation",
|
||||
"displayName": "Windows 10/11 专业教育版",
|
||||
"productKey": "6TP4R-GNPTD-KYYHQ-7B7DP-J447Y",
|
||||
"appliesTo": "Windows 10/11 Pro Education"
|
||||
},
|
||||
{
|
||||
"editionId": "ProfessionalEducationN",
|
||||
"displayName": "Windows 10/11 专业教育版 N",
|
||||
"productKey": "YVWGF-BXNMC-HTQYQ-CPQ99-66QFC",
|
||||
"appliesTo": "Windows 10/11 Pro Education N"
|
||||
},
|
||||
{
|
||||
"editionId": "Education",
|
||||
"displayName": "Windows 10/11 教育版",
|
||||
"productKey": "NW6C2-QMPVW-D7KKK-3GKT6-VCFB2",
|
||||
"appliesTo": "Windows 10/11 Education"
|
||||
},
|
||||
{
|
||||
"editionId": "EducationN",
|
||||
"displayName": "Windows 10/11 教育版 N",
|
||||
"productKey": "2WH4N-8QGBV-H22JP-CT43Q-MDWWJ",
|
||||
"appliesTo": "Windows 10/11 Education N"
|
||||
},
|
||||
{
|
||||
"editionId": "Enterprise",
|
||||
"displayName": "Windows 10/11 企业版",
|
||||
"productKey": "NPPR9-FWDCX-D2C8J-H872K-2YT43",
|
||||
"appliesTo": "Windows 10/11 Enterprise"
|
||||
},
|
||||
{
|
||||
"editionId": "EnterpriseN",
|
||||
"displayName": "Windows 10/11 企业版 N",
|
||||
"productKey": "DPH2V-TTNVB-4X9Q3-TJR4H-KHJW4",
|
||||
"appliesTo": "Windows 10/11 Enterprise N"
|
||||
},
|
||||
{
|
||||
"editionId": "EnterpriseG",
|
||||
"displayName": "Windows 10/11 企业版 G",
|
||||
"productKey": "YYVX9-NTFWV-6MDM3-9PT4T-4M68B",
|
||||
"appliesTo": "Windows 10/11 Enterprise G"
|
||||
},
|
||||
{
|
||||
"editionId": "EnterpriseGN",
|
||||
"displayName": "Windows 10/11 企业版 G N",
|
||||
"productKey": "44RPN-FTY23-9VTTB-MP9BX-T84FV",
|
||||
"appliesTo": "Windows 10/11 Enterprise G N"
|
||||
},
|
||||
{
|
||||
"editionId": "EnterpriseS",
|
||||
"displayName": "Windows 10/11 企业版 LTSC",
|
||||
"productKey": "M7XTQ-FN8P6-TTKYV-9D4CC-J462D",
|
||||
"appliesTo": "Enterprise LTSC 2019/2021/2024"
|
||||
},
|
||||
{
|
||||
"editionId": "EnterpriseSN",
|
||||
"displayName": "Windows 10/11 企业版 LTSC N",
|
||||
"productKey": "92NFX-8DJQP-P6BBQ-THF9C-7CG2H",
|
||||
"appliesTo": "Enterprise LTSC N 2019/2021/2024"
|
||||
},
|
||||
{
|
||||
"editionId": "Core",
|
||||
"displayName": "Windows 家庭版(不支持 KMS 客户端激活)",
|
||||
"productKey": "",
|
||||
"appliesTo": "Windows 10/11 Home",
|
||||
"supported": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# 架构说明
|
||||
|
||||
## 项目组成
|
||||
|
||||
```text
|
||||
WindowsToolbox.App
|
||||
WPF 界面、MVVM、UAC 客户端
|
||||
|
||||
WindowsToolbox.ElevatedHost
|
||||
固定命令白名单、管理员权限系统修改
|
||||
|
||||
WindowsToolbox.Cli
|
||||
当前用户桌面归档计划任务入口
|
||||
|
||||
WindowsToolbox.Infrastructure
|
||||
注册表、进程、任务计划、快照、各功能实现
|
||||
|
||||
WindowsToolbox.Core
|
||||
命令、状态、结果、快照和配置模型
|
||||
|
||||
WindowsToolbox.Verification
|
||||
不修改系统的自动验证程序
|
||||
```
|
||||
|
||||
## 执行流程
|
||||
|
||||
```text
|
||||
用户点击功能
|
||||
→ 主程序生成强类型 ToolboxCommand
|
||||
→ 普通文件操作直接执行
|
||||
→ 系统操作写入 Pending 目录并请求 UAC
|
||||
→ ElevatedHost 验证路径、时效和命令类型
|
||||
→ 首次操作保存基线快照
|
||||
→ 执行固定实现
|
||||
→ 读取实际状态进行验证
|
||||
→ 写入结果和操作历史
|
||||
→ 主程序刷新系统状态
|
||||
```
|
||||
|
||||
管理员执行器只接受枚举中的命令以及经过各模块验证的参数,不提供任意 CMD、PowerShell 或脚本执行入口。
|
||||
|
||||
## 扩展新功能
|
||||
|
||||
新增功能时应按以下顺序:
|
||||
|
||||
1. 在 `ToolboxCommandKind` 中添加固定命令。
|
||||
2. 在基础设施项目中实现模块服务。
|
||||
3. 确定原始状态的完整快照范围。
|
||||
4. 在 `ToolboxCommandDispatcher` 注册执行分支和提权要求。
|
||||
5. 在 WPF ViewModel 暴露命令,在页面添加状态、风险说明、应用和恢复入口。
|
||||
6. 向 Verification 项目增加非破坏性验证。
|
||||
|
||||
每个修改操作必须满足幂等、可验证和尽可能可恢复;不能只根据进程退出码显示成功。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 系统操作边界
|
||||
|
||||
## 右键菜单与快捷方式箭头
|
||||
|
||||
经典菜单使用当前用户 Shell 类注册项,并在修改后由普通权限主程序自动重启 Explorer。检测到 Atlas OS 时,同时维护 Atlas Toolbox 使用的状态镜像,实际状态仍以 Explorer 读取的 CLSID 项为准。快捷方式箭头使用系统 `Shell Icons` 覆盖值和工具生成的透明 ICO。
|
||||
|
||||
## Windows 更新
|
||||
|
||||
强力关闭模式管理:
|
||||
|
||||
- Windows Update 自动更新策略。
|
||||
- `wuauserv`、`UsoSvc` 和 `WaaSMedicSvc` 的可控制启动状态。
|
||||
- Update Orchestrator 与 Windows Update 中存在且允许修改的计划任务。
|
||||
- `WindowsToolbox\UpdateGuard` 每日守护任务。
|
||||
|
||||
部分服务和任务在不同 Windows 构建中受系统保护。模块会保留每一步结果,不把拒绝访问伪装成成功。
|
||||
|
||||
暂停模式写入开始和结束时间,最长限制为 35 天。自动续期任务每天重新请求所选暂停长度并再次验证。
|
||||
|
||||
## Windows 安全
|
||||
|
||||
模块使用 Defender 和防火墙的 Windows PowerShell Cmdlet。它不会停止 Security Center 核心服务,不会修改系统文件所有权,也不会绕过防篡改保护。恢复时按基线快照逐项恢复 Defender 偏好、三个防火墙配置和通知策略。
|
||||
|
||||
## 桌面归档
|
||||
|
||||
枚举桌面根目录中的文件和文件夹。默认排除:
|
||||
|
||||
- `desktop.ini`
|
||||
- `.lnk` 和 `.url`
|
||||
- 带 System 属性的文件或文件夹
|
||||
- 重解析点、目录联接及其他可能指向桌面外部的特殊目录
|
||||
|
||||
普通隐藏文件会归档。虚拟的“此电脑”“回收站”等系统图标不属于文件系统枚举范围,因此不会移动。同名目标采用 `名称 (1).扩展名` 或 `文件夹 (1)` 递增。每次执行都生成恢复清单;撤销只移动仍然存在于归档目录中的项目。
|
||||
|
||||
## KMS
|
||||
|
||||
密钥目录位于 `data\kms-keys.json`,可以手动维护。激活顺序为:
|
||||
|
||||
1. 验证版本、密钥格式、主机和端口。
|
||||
2. TCP 测试本地 KMS 主机。
|
||||
3. 保存原 KMS 主机和端口。
|
||||
4. 安装所选 GVLK。
|
||||
5. 设置 KMS 主机。
|
||||
6. 请求 Windows 激活。
|
||||
|
||||
如果连接测试失败,不修改产品密钥。
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.423",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Release',
|
||||
[switch]$SkipRestore
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$dotnetExe = Join-Path $projectRoot '.tools\dotnet\dotnet.exe'
|
||||
$nugetConfig = Join-Path $projectRoot 'NuGet.Config'
|
||||
$outputRoot = Join-Path $projectRoot 'artifacts\WindowsToolbox'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $dotnetExe)) {
|
||||
throw 'Local .NET SDK was not found under .tools\dotnet.'
|
||||
}
|
||||
|
||||
$env:DOTNET_CLI_HOME = Join-Path $projectRoot '.tools\dotnet-home'
|
||||
$env:NUGET_PACKAGES = Join-Path $projectRoot '.tools\nuget'
|
||||
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
|
||||
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
|
||||
|
||||
if (-not $SkipRestore) {
|
||||
& $dotnetExe restore (Join-Path $projectRoot 'WindowsToolbox.sln') --configfile $nugetConfig
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Restore failed.' }
|
||||
}
|
||||
|
||||
$commonArguments = @(
|
||||
'--configuration', $Configuration,
|
||||
'--framework', 'net8.0-windows',
|
||||
'--self-contained', 'false',
|
||||
'--no-restore',
|
||||
'--output', $outputRoot,
|
||||
'-p:DebugType=None',
|
||||
'-p:DebugSymbols=false'
|
||||
)
|
||||
|
||||
& $dotnetExe publish (Join-Path $projectRoot 'src\WindowsToolbox.ElevatedHost\WindowsToolbox.ElevatedHost.csproj') @commonArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Elevated host publish failed.' }
|
||||
|
||||
& $dotnetExe publish (Join-Path $projectRoot 'src\WindowsToolbox.Cli\WindowsToolbox.Cli.csproj') @commonArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'CLI publish failed.' }
|
||||
|
||||
& $dotnetExe publish (Join-Path $projectRoot 'src\WindowsToolbox.App\WindowsToolbox.App.csproj') @commonArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WPF app publish failed.' }
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot 'README.md') -Destination (Join-Path $outputRoot 'README.md') -Force
|
||||
|
||||
Write-Host "Published to: $outputRoot" -ForegroundColor Green
|
||||
Write-Host 'Run WindowsToolbox.App.exe to start.' -ForegroundColor Green
|
||||
@@ -0,0 +1,62 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ASSETS = ROOT / "assets"
|
||||
SCALE = 4
|
||||
SIZE = 256
|
||||
CANVAS = SIZE * SCALE
|
||||
|
||||
|
||||
def scaled_points(points):
|
||||
return [(x * SCALE, y * SCALE) for x, y in points]
|
||||
|
||||
|
||||
mask = Image.new("L", (CANVAS, CANVAS), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.rounded_rectangle(
|
||||
(8 * SCALE, 8 * SCALE, 248 * SCALE, 248 * SCALE),
|
||||
radius=54 * SCALE,
|
||||
fill=255,
|
||||
)
|
||||
|
||||
background = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
|
||||
pixels = background.load()
|
||||
for y in range(CANVAS):
|
||||
ratio = y / max(1, CANVAS - 1)
|
||||
red = round(59 + (29 - 59) * ratio)
|
||||
green = round(130 + (78 - 130) * ratio)
|
||||
blue = round(246 + (216 - 246) * ratio)
|
||||
for x in range(CANVAS):
|
||||
pixels[x, y] = (red, green, blue, mask.getpixel((x, y)))
|
||||
|
||||
draw = ImageDraw.Draw(background, "RGBA")
|
||||
highlight = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
|
||||
highlight_draw = ImageDraw.Draw(highlight, "RGBA")
|
||||
highlight_draw.ellipse(
|
||||
(-60 * SCALE, -110 * SCALE, 250 * SCALE, 150 * SCALE),
|
||||
fill=(255, 255, 255, 22),
|
||||
)
|
||||
background.alpha_composite(highlight)
|
||||
|
||||
w_shape = [
|
||||
(44, 66), (78, 66), (105, 157), (128, 98),
|
||||
(129, 98), (152, 157), (179, 66), (212, 66),
|
||||
(174, 192), (139, 192), (128, 161), (116, 192),
|
||||
(82, 192),
|
||||
]
|
||||
shadow_shape = [(x, y + 4) for x, y in w_shape]
|
||||
draw.polygon(scaled_points(shadow_shape), fill=(10, 35, 90, 45))
|
||||
draw.polygon(scaled_points(w_shape), fill=(255, 255, 255, 255))
|
||||
|
||||
icon = background.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||
icon.save(ASSETS / "WindowsToolbox.png", "PNG", optimize=True)
|
||||
icon.save(
|
||||
ASSETS / "WindowsToolbox.ico",
|
||||
"ICO",
|
||||
sizes=[(16, 16), (20, 20), (24, 24), (32, 32), (40, 40), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
print(ASSETS / "WindowsToolbox.ico")
|
||||
@@ -0,0 +1,128 @@
|
||||
<Application x:Class="WindowsToolbox.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:WindowsToolbox.App"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<local:PageVisibilityConverter x:Key="PageVisibilityConverter" />
|
||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#F3F6FA" />
|
||||
<SolidColorBrush x:Key="SidebarBrush" Color="#111827" />
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#1D4ED8" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#B91C1C" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#172033" />
|
||||
<SolidColorBrush x:Key="MutedTextBrush" Color="#64748B" />
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#E2E8F0" />
|
||||
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Microsoft YaHei UI" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
|
||||
<Setter Property="TextOptions.TextRenderingMode" Value="ClearType" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CardStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="22" />
|
||||
<Setter Property="Margin" Value="0,0,0,16" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PageTitleStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DescriptionStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="BaseButtonStyle" TargetType="Button">
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
<Setter Property="Padding" Value="18,8" />
|
||||
<Setter Property="Margin" Value="0,10,10,0" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="ButtonBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.88" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.72" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButtonStyle" TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
|
||||
<Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButtonStyle" TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
|
||||
<Setter Property="Background" Value="#EFF4FA" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DangerButtonStyle" TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="InputStyle" TargetType="TextBox">
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderBrush" Value="#CBD5E1" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,10,0,0" />
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
|
||||
namespace WindowsToolbox.App;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace WindowsToolbox.App;
|
||||
|
||||
public sealed class PageVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
string.Equals(value?.ToString(), parameter?.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<Window x:Class="WindowsToolbox.App.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Windows 工具箱"
|
||||
Icon="/Assets/WindowsToolbox.ico"
|
||||
Width="1180" Height="780" MinWidth="1000" MinHeight="680"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BackgroundBrush}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="238" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Background="{StaticResource SidebarBrush}">
|
||||
<Grid Margin="18,24,18,18">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Margin="8,0,8,28">
|
||||
<Image Source="/Assets/WindowsToolbox.ico" Width="46" Height="46" HorizontalAlignment="Left" />
|
||||
<TextBlock Text="Windows 工具箱" Foreground="White" FontSize="20" FontWeight="SemiBold" Margin="0,14,0,2" />
|
||||
<TextBlock Text="系统设置 · 可恢复" Foreground="#94A3B8" FontSize="12" />
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding Navigation}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Command="{Binding DataContext.NavigateCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}" Cursor="Hand" Padding="13,11" Margin="0,2"
|
||||
HorizontalContentAlignment="Stretch" BorderThickness="0">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#CBD5E1" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="NavBorder" Background="{TemplateBinding Background}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="NavBorder" Property="Background" Value="#1E293B" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||
<Setter Property="Background" Value="#2563EB" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="28" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Glyph}" FontSize="16" Foreground="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Title}" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border Grid.Row="2" Background="#1E293B" CornerRadius="8" Padding="12">
|
||||
<StackPanel>
|
||||
<TextBlock Text="当前系统" Foreground="#94A3B8" FontSize="11" />
|
||||
<TextBlock Text="{Binding OsSummary}" Foreground="White" FontSize="12" TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid Margin="34,28,34,24">
|
||||
<!-- Dashboard -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Dashboard}">
|
||||
<DockPanel Margin="0,0,0,22">
|
||||
<StackPanel DockPanel.Dock="Left">
|
||||
<TextBlock Text="系统概览" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="实时读取系统状态;修改后会重新验证。" Style="{StaticResource DescriptionStyle}" />
|
||||
</StackPanel>
|
||||
<Button DockPanel.Dock="Right" Content="刷新状态" Command="{Binding RefreshCommand}"
|
||||
Style="{StaticResource SecondaryButtonStyle}" HorizontalAlignment="Right" Margin="12,0,0,0" />
|
||||
</DockPanel>
|
||||
|
||||
<UniformGrid Columns="2">
|
||||
<Border Style="{StaticResource CardStyle}" Margin="0,0,8,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="操作系统" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBlock Text="{Binding OsSummary}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TextBrush}" Margin="0,8,0,0" />
|
||||
<TextBlock Text="{Binding EditionSummary}" Style="{StaticResource DescriptionStyle}" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}" Margin="8,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Windows 更新" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBlock Text="{Binding UpdateStatusText}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TextBrush}" Margin="0,8,0,0" />
|
||||
<TextBlock Text="策略、暂停时间和守护任务会分别记录" Style="{StaticResource DescriptionStyle}" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}" Margin="0,0,8,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Defender" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBlock Text="{Binding DefenderStatusText}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TextBrush}" Margin="0,8,0,0" />
|
||||
<TextBlock Text="{Binding TamperStatusText}" Style="{StaticResource DescriptionStyle}" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}" Margin="8,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Windows 激活" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBlock Text="{Binding ActivationStatusText}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TextBrush}" Margin="0,8,0,0" />
|
||||
<TextBlock Text="{Binding KmsStatusText}" Style="{StaticResource DescriptionStyle}" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="最近一次操作" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="{Binding LastMessage}" Style="{StaticResource DescriptionStyle}" />
|
||||
<ItemsControl ItemsSource="{Binding LastSteps}" Margin="0,10,0,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,4">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="20" /><ColumnDefinition Width="150" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding StatusGlyph}" Foreground="{Binding StatusBrush}" FontWeight="Bold" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" FontWeight="SemiBold" Foreground="{StaticResource TextBrush}" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Message}" TextWrapping="Wrap" Foreground="{StaticResource MutedTextBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Appearance -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Appearance}">
|
||||
<TextBlock Text="外观与资源管理器" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="修改后可统一重启资源管理器使设置生效。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Windows 11 经典右键菜单" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="恢复 Windows 10 风格的完整右键菜单;仅 Windows 11 使用该设置。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel>
|
||||
<Button Content="启用经典菜单" Command="{Binding EnableClassicMenuCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="恢复 Windows 11 菜单" Command="{Binding RestoreContextMenuCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="快捷方式小箭头" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="使用工具生成的透明图标覆盖快捷方式箭头,并保存原始注册表值。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel>
|
||||
<Button Content="隐藏小箭头" Command="{Binding HideShortcutArrowCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="恢复小箭头" Command="{Binding RestoreShortcutArrowCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Content="重启资源管理器" Command="{Binding RestartExplorerCommand}" Style="{StaticResource SecondaryButtonStyle}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Updates -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Updates}">
|
||||
<TextBlock Text="Windows 更新" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="暂停和强力关闭共用一份原始状态快照;恢复会回到首次修改前。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="230" /></Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="暂停更新" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="单次最长 35 天;可创建每日检查任务自动续期。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel>
|
||||
<Button Content="立即暂停" Command="{Binding PauseUpdateCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="应用自动续期设置" Command="{Binding ConfigureUpdateRenewalCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="暂停天数" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<ComboBox ItemsSource="{Binding PauseDayOptions}" SelectedItem="{Binding SelectedPauseDays}" Margin="0,6,0,4" />
|
||||
<CheckBox Content="每天自动检查并续期" IsChecked="{Binding AutoRenewEnabled}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}" BorderBrush="#FECACA" Background="#FFF7F7">
|
||||
<StackPanel>
|
||||
<TextBlock Text="强力关闭自动更新" Style="{StaticResource SectionTitleStyle}" Foreground="#991B1B" />
|
||||
<TextBlock Text="设置禁止更新策略,关闭可控制的更新服务和任务,并创建每日守护任务。受保护组件若被系统拒绝,会在操作详情中标明。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel>
|
||||
<Button Content="强力关闭更新" Command="{Binding DisableUpdateCommand}" Style="{StaticResource DangerButtonStyle}" />
|
||||
<Button Content="恢复更新原始状态" Command="{Binding RestoreUpdateCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Security -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Security}">
|
||||
<TextBlock Text="安全与登录" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="只使用 Windows 可控制的策略和 Cmdlet,不绕过防篡改或 TrustedInstaller。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
|
||||
<Border Style="{StaticResource CardStyle}" BorderBrush="#FECACA" Background="#FFF7F7">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Defender 防护" Style="{StaticResource SectionTitleStyle}" Foreground="#991B1B" />
|
||||
<TextBlock Text="执行前需要在 Windows 安全中心手动关闭防篡改保护。工具会保存 Defender、通知及防火墙原始设置。" Style="{StaticResource DescriptionStyle}" />
|
||||
<CheckBox Content="同时关闭 Domain、Public、Private 三个防火墙配置" IsChecked="{Binding DisableFirewall}" />
|
||||
<WrapPanel>
|
||||
<Button Content="关闭所选防护" Command="{Binding DisableSecurityCommand}" Style="{StaticResource DangerButtonStyle}" />
|
||||
<Button Content="恢复安全设置" Command="{Binding RestoreSecurityCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
<Button Content="打开 Windows 安全中心" Command="{Binding OpenWindowsSecurityCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="免密码自动登录" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="恢复 netplwiz 中可能被隐藏的账户密码复选框,然后由你在 Windows 自带窗口中完成设置。工具不会读取或保存密码。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel>
|
||||
<Button Content="准备并打开账户控制" Command="{Binding PrepareAutoLogonCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="恢复选项注册表" Command="{Binding RestoreAutoLogonCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Desktop -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Desktop}">
|
||||
<TextBlock Text="桌面文件归档" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="归档到 yyyy-MM-dd 子目录;包含普通文件夹,仅排除快捷方式和系统项。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="目标位置" Style="{StaticResource SectionTitleStyle}" />
|
||||
<Grid Margin="0,8,0,4">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<TextBox Text="{Binding DesktopDestination, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource InputStyle}" />
|
||||
<Button Grid.Column="1" Content="选择文件夹" Command="{Binding BrowseDesktopDestinationCommand}" Style="{StaticResource SecondaryButtonStyle}" Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
<WrapPanel>
|
||||
<Button Content="立即归档" Command="{Binding ArchiveDesktopCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="撤销最近一次归档" Command="{Binding UndoDesktopArchiveCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="每月自动执行" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock Text="使用当前登录用户的任务计划;电脑关机错过时间后会在下次可用时执行。" Style="{StaticResource DescriptionStyle}" />
|
||||
<WrapPanel Margin="0,10,0,0">
|
||||
<StackPanel Width="180" Margin="0,0,14,0">
|
||||
<TextBlock Text="每月日期(1—28)" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<ComboBox ItemsSource="{Binding MonthlyDayOptions}" SelectedItem="{Binding SelectedMonthlyDay}" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Width="180">
|
||||
<TextBlock Text="执行时间(HH:mm)" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBox Text="{Binding ScheduleTime}" Style="{StaticResource InputStyle}" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<CheckBox Content="启用每月自动归档" IsChecked="{Binding DesktopScheduleEnabled}" />
|
||||
<Button Content="保存计划任务" Command="{Binding ConfigureDesktopScheduleCommand}" Style="{StaticResource PrimaryButtonStyle}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Activation -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=Activation}">
|
||||
<TextBlock Text="Windows KMS 激活" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="内置微软公开 GVLK;KMS 主机由你填写并在连接成功后才安装密钥。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Windows 版本" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<ComboBox ItemsSource="{Binding KmsKeys}" SelectedItem="{Binding SelectedKmsKey}" DisplayMemberPath="DisplayName" Margin="0,6,0,14" />
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="130" /></Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,12,0">
|
||||
<TextBlock Text="本地 KMS 主机" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBox Text="{Binding KmsHost, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource InputStyle}" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="端口" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBox Text="{Binding KmsPort, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource InputStyle}" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border Background="#FFF7ED" BorderBrush="#FED7AA" BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,16,0,4">
|
||||
<TextBlock Text="提示:Windows 客户端 KMS 需要服务器达到 25 个不同客户端的激活门槛。替换产品密钥后,Windows 无法导出并自动恢复旧完整密钥。" TextWrapping="Wrap" Foreground="#9A3412" />
|
||||
</Border>
|
||||
<WrapPanel>
|
||||
<Button Content="连接并激活" Command="{Binding ActivateWindowsCommand}" Style="{StaticResource PrimaryButtonStyle}" />
|
||||
<Button Content="恢复原 KMS 主机" Command="{Binding RestoreKmsCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- History -->
|
||||
<StackPanel Visibility="{Binding SelectedPage, Converter={StaticResource PageVisibilityConverter}, ConverterParameter=History}">
|
||||
<DockPanel Margin="0,0,0,22">
|
||||
<StackPanel DockPanel.Dock="Left">
|
||||
<TextBlock Text="操作历史" Style="{StaticResource PageTitleStyle}" />
|
||||
<TextBlock Text="最近 100 次操作及执行结果。" Style="{StaticResource DescriptionStyle}" />
|
||||
</StackPanel>
|
||||
<Button DockPanel.Dock="Right" Content="刷新历史" Command="{Binding RefreshHistoryCommand}" Style="{StaticResource SecondaryButtonStyle}" />
|
||||
</DockPanel>
|
||||
<ItemsControl ItemsSource="{Binding History}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource CardStyle}" Padding="16" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="145" /><ColumnDefinition Width="190" /><ColumnDefinition Width="*" /><ColumnDefinition Width="70" /></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding TimestampText}" Foreground="{StaticResource MutedTextBrush}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Action}" Foreground="{StaticResource TextBrush}" FontWeight="SemiBold" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Summary}" Foreground="{StaticResource MutedTextBrush}" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding StatusText}" Foreground="{Binding StatusBrush}" FontWeight="SemiBold" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,1,0,0" Padding="18,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="Auto" /><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding StatusBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding LastMessage}" Foreground="{StaticResource MutedTextBrush}" Margin="9,0,0,0" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<ProgressBar Width="120" Height="4" IsIndeterminate="True" Margin="0,0,10,0" />
|
||||
<TextBlock Text="正在执行…" Foreground="{StaticResource MutedTextBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace WindowsToolbox.App;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _viewModel = new();
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = _viewModel;
|
||||
Loaded += OnLoaded;
|
||||
}
|
||||
|
||||
private async void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Loaded -= OnLoaded;
|
||||
await _viewModel.InitializeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
using WindowsToolbox.Infrastructure;
|
||||
|
||||
namespace WindowsToolbox.App;
|
||||
|
||||
public enum ToolboxPage
|
||||
{
|
||||
Dashboard,
|
||||
Appearance,
|
||||
Updates,
|
||||
Security,
|
||||
Desktop,
|
||||
Activation,
|
||||
History
|
||||
}
|
||||
|
||||
public sealed class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly ElevatedCommandClient _commands = new();
|
||||
private readonly SystemStatusService _statusService = new(new RegistryStore(), new ProcessRunner());
|
||||
private readonly OperationLogStore _logStore = new();
|
||||
private readonly ActivationService _activationService = new(new RegistryStore(), new SnapshotStore(), new ProcessRunner());
|
||||
private SystemStatus? _status;
|
||||
private ToolboxPage _selectedPage;
|
||||
private bool _isBusy;
|
||||
private string _lastMessage = "准备读取系统状态…";
|
||||
private bool? _lastSucceeded;
|
||||
private int _selectedPauseDays = 35;
|
||||
private bool _autoRenewEnabled;
|
||||
private bool _disableFirewall;
|
||||
private string _desktopDestination;
|
||||
private bool _desktopScheduleEnabled;
|
||||
private int _selectedMonthlyDay = 1;
|
||||
private string _scheduleTime = "09:00";
|
||||
private KmsKeyEntry? _selectedKmsKey;
|
||||
private string _kmsHost = "127.0.0.1";
|
||||
private string _kmsPort = "1688";
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
var preferredDestination = Directory.Exists(@"D:\")
|
||||
? @"D:\desktop"
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "DesktopArchive");
|
||||
_desktopDestination = preferredDestination;
|
||||
|
||||
Navigation =
|
||||
[
|
||||
new("系统概览", "⌂", ToolboxPage.Dashboard, true),
|
||||
new("外观与资源管理器", "◈", ToolboxPage.Appearance),
|
||||
new("Windows 更新", "↻", ToolboxPage.Updates),
|
||||
new("安全与登录", "◇", ToolboxPage.Security),
|
||||
new("桌面归档", "▣", ToolboxPage.Desktop),
|
||||
new("KMS 激活", "✓", ToolboxPage.Activation),
|
||||
new("操作历史", "≡", ToolboxPage.History)
|
||||
];
|
||||
|
||||
KmsKeys = new ObservableCollection<KmsKeyEntry>(_activationService.LoadCatalog());
|
||||
_selectedKmsKey = KmsKeys.FirstOrDefault(x => x.EditionId == "Professional") ?? KmsKeys.FirstOrDefault();
|
||||
|
||||
NavigateCommand = new RelayCommand(parameter => Navigate((NavigationItemViewModel)parameter!));
|
||||
RefreshCommand = new AsyncRelayCommand(_ => RefreshStatusAsync(), () => !IsBusy);
|
||||
RefreshHistoryCommand = new AsyncRelayCommand(_ => RefreshHistoryAsync(), () => !IsBusy);
|
||||
EnableClassicMenuCommand = Action(ToolboxCommandKind.EnableClassicContextMenu);
|
||||
RestoreContextMenuCommand = Action(ToolboxCommandKind.RestoreContextMenu);
|
||||
HideShortcutArrowCommand = Action(ToolboxCommandKind.HideShortcutArrow);
|
||||
RestoreShortcutArrowCommand = Action(ToolboxCommandKind.RestoreShortcutArrow);
|
||||
PauseUpdateCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.PauseWindowsUpdate,
|
||||
new() { ["days"] = SelectedPauseDays.ToString() }), () => !IsBusy);
|
||||
ConfigureUpdateRenewalCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.ConfigureUpdateRenewal,
|
||||
new() { ["enabled"] = AutoRenewEnabled.ToString(), ["days"] = SelectedPauseDays.ToString() }), () => !IsBusy);
|
||||
DisableUpdateCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.DisableWindowsUpdate,
|
||||
confirmation: "强力关闭会停止更新服务、禁用可控制的更新任务并创建每日守护任务。确定继续吗?"), () => !IsBusy);
|
||||
RestoreUpdateCommand = Action(ToolboxCommandKind.RestoreWindowsUpdate);
|
||||
DisableSecurityCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.DisableDefenderProtection,
|
||||
new() { ["firewall"] = DisableFirewall.ToString() },
|
||||
DisableFirewall
|
||||
? "这会关闭 Defender 防护及三个 Windows 防火墙配置。确定已经关闭防篡改保护并继续吗?"
|
||||
: "这会关闭 Defender 的实时、行为、脚本和云端防护。确定已经关闭防篡改保护并继续吗?"), () => !IsBusy);
|
||||
RestoreSecurityCommand = Action(ToolboxCommandKind.RestoreDefenderProtection);
|
||||
PrepareAutoLogonCommand = new AsyncRelayCommand(async _ =>
|
||||
{
|
||||
var result = await ExecuteAsync(ToolboxCommandKind.PrepareAutoLogon,
|
||||
confirmation: "自动登录会降低本机物理访问安全性。工具不会保存密码,是否继续打开 Windows 账户控制?");
|
||||
if (result?.Success == true)
|
||||
{
|
||||
AutoLogonService.OpenNetplwiz();
|
||||
}
|
||||
}, () => !IsBusy);
|
||||
RestoreAutoLogonCommand = Action(ToolboxCommandKind.RestoreAutoLogonPreparation);
|
||||
BrowseDesktopDestinationCommand = new RelayCommand(_ => BrowseDestination());
|
||||
ArchiveDesktopCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.ArchiveDesktop, DesktopArguments()), () => !IsBusy);
|
||||
UndoDesktopArchiveCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.UndoDesktopArchive,
|
||||
confirmation: "将按最近一次恢复清单把仍在归档目录中的文件移回桌面。确定继续吗?"), () => !IsBusy);
|
||||
ConfigureDesktopScheduleCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.ConfigureDesktopSchedule,
|
||||
DesktopArguments(includeSchedule: true)), () => !IsBusy);
|
||||
ActivateWindowsCommand = new AsyncRelayCommand(_ => ActivateAsync(), () => !IsBusy);
|
||||
RestoreKmsCommand = new AsyncRelayCommand(_ => ExecuteAsync(
|
||||
ToolboxCommandKind.RestoreKmsConfiguration,
|
||||
confirmation: "这会恢复或清除原 KMS 主机配置,但无法恢复未知的旧完整产品密钥。确定继续吗?"), () => !IsBusy);
|
||||
RestartExplorerCommand = new AsyncRelayCommand(_ => RestartExplorerAsync(), () => !IsBusy);
|
||||
OpenWindowsSecurityCommand = new RelayCommand(_ => OpenWindowsSecurity());
|
||||
}
|
||||
|
||||
public ObservableCollection<NavigationItemViewModel> Navigation { get; }
|
||||
public ObservableCollection<KmsKeyEntry> KmsKeys { get; }
|
||||
public ObservableCollection<StepViewModel> LastSteps { get; } = [];
|
||||
public ObservableCollection<HistoryItemViewModel> History { get; } = [];
|
||||
public IReadOnlyList<int> PauseDayOptions { get; } = [7, 14, 21, 28, 35];
|
||||
public IReadOnlyList<int> MonthlyDayOptions { get; } = Enumerable.Range(1, 28).ToArray();
|
||||
|
||||
public ICommand NavigateCommand { get; }
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand RefreshHistoryCommand { get; }
|
||||
public ICommand EnableClassicMenuCommand { get; }
|
||||
public ICommand RestoreContextMenuCommand { get; }
|
||||
public ICommand HideShortcutArrowCommand { get; }
|
||||
public ICommand RestoreShortcutArrowCommand { get; }
|
||||
public ICommand PauseUpdateCommand { get; }
|
||||
public ICommand ConfigureUpdateRenewalCommand { get; }
|
||||
public ICommand DisableUpdateCommand { get; }
|
||||
public ICommand RestoreUpdateCommand { get; }
|
||||
public ICommand DisableSecurityCommand { get; }
|
||||
public ICommand RestoreSecurityCommand { get; }
|
||||
public ICommand PrepareAutoLogonCommand { get; }
|
||||
public ICommand RestoreAutoLogonCommand { get; }
|
||||
public ICommand BrowseDesktopDestinationCommand { get; }
|
||||
public ICommand ArchiveDesktopCommand { get; }
|
||||
public ICommand UndoDesktopArchiveCommand { get; }
|
||||
public ICommand ConfigureDesktopScheduleCommand { get; }
|
||||
public ICommand ActivateWindowsCommand { get; }
|
||||
public ICommand RestoreKmsCommand { get; }
|
||||
public ICommand RestartExplorerCommand { get; }
|
||||
public ICommand OpenWindowsSecurityCommand { get; }
|
||||
|
||||
public ToolboxPage SelectedPage
|
||||
{
|
||||
get => _selectedPage;
|
||||
private set => SetField(ref _selectedPage, value);
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetField(ref _isBusy, value))
|
||||
{
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string LastMessage
|
||||
{
|
||||
get => _lastMessage;
|
||||
private set => SetField(ref _lastMessage, value);
|
||||
}
|
||||
|
||||
public Brush StatusBrush => _lastSucceeded switch
|
||||
{
|
||||
true => Brushes.SeaGreen,
|
||||
false => Brushes.Crimson,
|
||||
null => Brushes.SlateGray
|
||||
};
|
||||
|
||||
public string OsSummary => _status is null ? "正在检测…" : $"{_status.ProductName} {_status.DisplayVersion}";
|
||||
public string EditionSummary => _status is null ? string.Empty : $"{_status.EditionId} · Build {_status.Build} · {(_status.IsAdministrator ? "管理员" : "标准权限")}";
|
||||
public string UpdateStatusText => _status?.UpdateMode ?? "正在检测…";
|
||||
public string DefenderStatusText => _status?.DefenderRealtimeProtectionEnabled switch { true => "实时防护开启", false => "实时防护关闭", _ => "状态不可用" };
|
||||
public string TamperStatusText => _status?.TamperProtectionEnabled switch { true => "防篡改保护:开启", false => "防篡改保护:关闭", _ => "防篡改保护状态不可用" };
|
||||
public string ActivationStatusText => _status?.ActivationStatus ?? "正在检测…";
|
||||
public string KmsStatusText => _status is null ? string.Empty : $"KMS:{(_status.KmsHost ?? "未指定")} · 密钥尾号:{(_status.PartialProductKey ?? "未知")}";
|
||||
|
||||
public int SelectedPauseDays { get => _selectedPauseDays; set => SetField(ref _selectedPauseDays, value); }
|
||||
public bool AutoRenewEnabled { get => _autoRenewEnabled; set => SetField(ref _autoRenewEnabled, value); }
|
||||
public bool DisableFirewall { get => _disableFirewall; set => SetField(ref _disableFirewall, value); }
|
||||
public string DesktopDestination { get => _desktopDestination; set => SetField(ref _desktopDestination, value); }
|
||||
public bool DesktopScheduleEnabled { get => _desktopScheduleEnabled; set => SetField(ref _desktopScheduleEnabled, value); }
|
||||
public int SelectedMonthlyDay { get => _selectedMonthlyDay; set => SetField(ref _selectedMonthlyDay, value); }
|
||||
public string ScheduleTime { get => _scheduleTime; set => SetField(ref _scheduleTime, value); }
|
||||
public KmsKeyEntry? SelectedKmsKey { get => _selectedKmsKey; set => SetField(ref _selectedKmsKey, value); }
|
||||
public string KmsHost { get => _kmsHost; set => SetField(ref _kmsHost, value); }
|
||||
public string KmsPort { get => _kmsPort; set => SetField(ref _kmsPort, value); }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await Task.WhenAll(
|
||||
RefreshStatusAsync(showBusy: false),
|
||||
RefreshHistoryAsync());
|
||||
}
|
||||
|
||||
private AsyncRelayCommand Action(ToolboxCommandKind kind) =>
|
||||
new(_ => ExecuteAsync(kind), () => !IsBusy);
|
||||
|
||||
private void Navigate(NavigationItemViewModel item)
|
||||
{
|
||||
foreach (var navigationItem in Navigation)
|
||||
{
|
||||
navigationItem.IsSelected = ReferenceEquals(navigationItem, item);
|
||||
}
|
||||
SelectedPage = item.Page;
|
||||
}
|
||||
|
||||
private async Task<CommandResult?> ExecuteAsync(
|
||||
ToolboxCommandKind kind,
|
||||
Dictionary<string, string>? arguments = null,
|
||||
string? confirmation = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(confirmation) && MessageBox.Show(
|
||||
confirmation, "请确认", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
LastMessage = "正在执行,请在出现 UAC 提示时确认…";
|
||||
_lastSucceeded = null;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
try
|
||||
{
|
||||
var result = await _commands.ExecuteAsync(kind, arguments);
|
||||
ApplyResult(result);
|
||||
if (result.Success && result.Steps.Any(step => step.RequiresRestart))
|
||||
{
|
||||
var restarted = await RestartExplorerCoreAsync();
|
||||
LastSteps.Add(new StepViewModel(new CommandStepResult(
|
||||
"重启资源管理器",
|
||||
restarted,
|
||||
restarted ? "已自动重启,设置现在生效" : "自动重启失败,请点击页面中的“重启资源管理器”")));
|
||||
LastMessage = restarted ? result.Summary + ",资源管理器已自动重启" : result.Summary + ",但资源管理器重启失败";
|
||||
_lastSucceeded = restarted;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
var refreshAdvanced = kind is ToolboxCommandKind.DisableDefenderProtection or
|
||||
ToolboxCommandKind.RestoreDefenderProtection or
|
||||
ToolboxCommandKind.ActivateWindowsKms or
|
||||
ToolboxCommandKind.RestoreKmsConfiguration;
|
||||
await RefreshStatusAsync(preserveMessage: true, includeAdvanced: refreshAdvanced);
|
||||
await RefreshHistoryAsync();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastMessage = ex.Message;
|
||||
_lastSucceeded = false;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync(bool preserveMessage = false, bool showBusy = true, bool includeAdvanced = true)
|
||||
{
|
||||
var wasBusy = IsBusy;
|
||||
if (showBusy)
|
||||
{
|
||||
IsBusy = true;
|
||||
}
|
||||
try
|
||||
{
|
||||
_status = await _statusService.DetectAsync(includeAdvanced, _status);
|
||||
if (!preserveMessage)
|
||||
{
|
||||
LastMessage = "系统状态已刷新";
|
||||
_lastSucceeded = true;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(OsSummary));
|
||||
OnPropertyChanged(nameof(EditionSummary));
|
||||
OnPropertyChanged(nameof(UpdateStatusText));
|
||||
OnPropertyChanged(nameof(DefenderStatusText));
|
||||
OnPropertyChanged(nameof(TamperStatusText));
|
||||
OnPropertyChanged(nameof(ActivationStatusText));
|
||||
OnPropertyChanged(nameof(KmsStatusText));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_status.KmsHost))
|
||||
{
|
||||
KmsHost = _status.KmsHost;
|
||||
}
|
||||
SelectedKmsKey = KmsKeys.FirstOrDefault(x => x.EditionId.Equals(_status.EditionId, StringComparison.OrdinalIgnoreCase))
|
||||
?? SelectedKmsKey;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!preserveMessage)
|
||||
{
|
||||
LastMessage = "状态检测失败:" + ex.Message;
|
||||
_lastSucceeded = false;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (showBusy)
|
||||
{
|
||||
IsBusy = wasBusy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshHistoryAsync()
|
||||
{
|
||||
var entries = await _logStore.ReadRecentAsync();
|
||||
History.Clear();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
History.Add(new HistoryItemViewModel(entry));
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyResult(CommandResult result)
|
||||
{
|
||||
LastMessage = result.Summary;
|
||||
_lastSucceeded = result.Success;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
LastSteps.Clear();
|
||||
foreach (var step in result.Steps)
|
||||
{
|
||||
LastSteps.Add(new StepViewModel(step));
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> DesktopArguments(bool includeSchedule = false)
|
||||
{
|
||||
var arguments = new Dictionary<string, string>
|
||||
{
|
||||
["profile"] = "default",
|
||||
["destination"] = DesktopDestination
|
||||
};
|
||||
if (includeSchedule)
|
||||
{
|
||||
arguments["enabled"] = DesktopScheduleEnabled.ToString();
|
||||
arguments["day"] = SelectedMonthlyDay.ToString();
|
||||
arguments["time"] = ScheduleTime;
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private async Task ActivateAsync()
|
||||
{
|
||||
if (SelectedKmsKey is null)
|
||||
{
|
||||
MessageBox.Show("请选择 Windows 版本。", "KMS 激活", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteAsync(ToolboxCommandKind.ActivateWindowsKms,
|
||||
new()
|
||||
{
|
||||
["edition"] = SelectedKmsKey.EditionId,
|
||||
["host"] = KmsHost,
|
||||
["port"] = KmsPort
|
||||
},
|
||||
$"将安装“{SelectedKmsKey.DisplayName}”的 GVLK,并连接 {KmsHost}:{KmsPort}。确定继续吗?");
|
||||
}
|
||||
|
||||
private void BrowseDestination()
|
||||
{
|
||||
var dialog = new OpenFolderDialog
|
||||
{
|
||||
Title = "选择桌面归档根目录",
|
||||
Multiselect = false,
|
||||
InitialDirectory = Directory.Exists(DesktopDestination) ? DesktopDestination : null
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
DesktopDestination = dialog.FolderName;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestartExplorerAsync()
|
||||
{
|
||||
if (MessageBox.Show("资源管理器窗口和任务栏会短暂关闭并自动重新启动。确定继续吗?",
|
||||
"重启资源管理器", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var restarted = await RestartExplorerCoreAsync();
|
||||
LastMessage = restarted ? "资源管理器已重新启动" : "资源管理器重启失败";
|
||||
_lastSucceeded = restarted;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastMessage = "重启资源管理器失败:" + ex.Message;
|
||||
_lastSucceeded = false;
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> RestartExplorerCoreAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var runner = new ProcessRunner();
|
||||
await runner.RunAsync("ie4uinit.exe", ["-ClearIconCache"]);
|
||||
await runner.RunAsync("taskkill.exe", ["/F", "/IM", "explorer.exe"]);
|
||||
await Task.Delay(350);
|
||||
return Process.Start(new ProcessStartInfo("explorer.exe") { UseShellExecute = true }) is not null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenWindowsSecurity()
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("windowsdefender:") { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NavigationItemViewModel(string title, string glyph, ToolboxPage page, bool isSelected = false) : ObservableObject
|
||||
{
|
||||
private bool _isSelected = isSelected;
|
||||
public string Title { get; } = title;
|
||||
public string Glyph { get; } = glyph;
|
||||
public ToolboxPage Page { get; } = page;
|
||||
public bool IsSelected { get => _isSelected; set => SetField(ref _isSelected, value); }
|
||||
}
|
||||
|
||||
public sealed class StepViewModel(CommandStepResult step)
|
||||
{
|
||||
public string Name { get; } = step.Name;
|
||||
public string Message { get; } = step.Message;
|
||||
public string StatusGlyph { get; } = step.Success ? "✓" : "×";
|
||||
public Brush StatusBrush { get; } = step.Success ? Brushes.SeaGreen : Brushes.Crimson;
|
||||
}
|
||||
|
||||
public sealed class HistoryItemViewModel(OperationLogEntry entry)
|
||||
{
|
||||
public string TimestampText { get; } = entry.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
|
||||
public string Action { get; } = Localize(entry.Action);
|
||||
public string Summary { get; } = entry.Summary;
|
||||
public string StatusText { get; } = entry.Success ? "成功" : "失败";
|
||||
public Brush StatusBrush { get; } = entry.Success ? Brushes.SeaGreen : Brushes.Crimson;
|
||||
|
||||
private static string Localize(string action) => Enum.TryParse<ToolboxCommandKind>(action, out var kind) ? kind switch
|
||||
{
|
||||
ToolboxCommandKind.EnableClassicContextMenu => "启用经典右键菜单",
|
||||
ToolboxCommandKind.RestoreContextMenu => "恢复右键菜单",
|
||||
ToolboxCommandKind.HideShortcutArrow => "隐藏快捷方式箭头",
|
||||
ToolboxCommandKind.RestoreShortcutArrow => "恢复快捷方式箭头",
|
||||
ToolboxCommandKind.PauseWindowsUpdate => "暂停 Windows 更新",
|
||||
ToolboxCommandKind.DisableWindowsUpdate => "强力关闭 Windows 更新",
|
||||
ToolboxCommandKind.RestoreWindowsUpdate => "恢复 Windows 更新",
|
||||
ToolboxCommandKind.ConfigureUpdateRenewal => "更新暂停自动续期",
|
||||
ToolboxCommandKind.DisableDefenderProtection => "关闭 Windows 防护",
|
||||
ToolboxCommandKind.RestoreDefenderProtection => "恢复 Windows 防护",
|
||||
ToolboxCommandKind.PrepareAutoLogon => "准备自动登录",
|
||||
ToolboxCommandKind.RestoreAutoLogonPreparation => "恢复自动登录选项",
|
||||
ToolboxCommandKind.ArchiveDesktop => "桌面归档",
|
||||
ToolboxCommandKind.UndoDesktopArchive => "撤销桌面归档",
|
||||
ToolboxCommandKind.ConfigureDesktopSchedule => "桌面归档计划",
|
||||
ToolboxCommandKind.ActivateWindowsKms => "KMS 激活",
|
||||
ToolboxCommandKind.RestoreKmsConfiguration => "恢复 KMS 配置",
|
||||
_ => action
|
||||
} : action;
|
||||
}
|
||||
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
public sealed class RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null) : ICommand
|
||||
{
|
||||
public event EventHandler? CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => canExecute?.Invoke(parameter) ?? true;
|
||||
public void Execute(object? parameter) => execute(parameter);
|
||||
}
|
||||
|
||||
public sealed class AsyncRelayCommand(Func<object?, Task> execute, Func<bool>? canExecute = null) : ICommand
|
||||
{
|
||||
private bool _running;
|
||||
public event EventHandler? CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => !_running && (canExecute?.Invoke() ?? true);
|
||||
|
||||
public async void Execute(object? parameter)
|
||||
{
|
||||
if (!CanExecute(parameter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_running = true;
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
try
|
||||
{
|
||||
await execute(parameter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WindowsToolbox.Core\WindowsToolbox.Core.csproj" />
|
||||
<ProjectReference Include="..\WindowsToolbox.Infrastructure\WindowsToolbox.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\WindowsToolbox.ElevatedHost\WindowsToolbox.ElevatedHost.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\WindowsToolbox.Cli\WindowsToolbox.Cli.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\data\kms-keys.json" Link="data\kms-keys.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Resource Include="..\..\assets\WindowsToolbox.ico" Link="Assets\WindowsToolbox.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyCompanionExecutables" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<CompanionOutput Include="..\WindowsToolbox.ElevatedHost\bin\$(Configuration)\net8.0-windows\WindowsToolbox.ElevatedHost.exe" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.ElevatedHost\bin\$(Configuration)\net8.0-windows\WindowsToolbox.ElevatedHost.dll" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.ElevatedHost\bin\$(Configuration)\net8.0-windows\WindowsToolbox.ElevatedHost.deps.json" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.ElevatedHost\bin\$(Configuration)\net8.0-windows\WindowsToolbox.ElevatedHost.runtimeconfig.json" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.Cli\bin\$(Configuration)\net8.0-windows\WindowsToolbox.Cli.exe" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.Cli\bin\$(Configuration)\net8.0-windows\WindowsToolbox.Cli.dll" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.Cli\bin\$(Configuration)\net8.0-windows\WindowsToolbox.Cli.deps.json" />
|
||||
<CompanionOutput Include="..\WindowsToolbox.Cli\bin\$(Configuration)\net8.0-windows\WindowsToolbox.Cli.runtimeconfig.json" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(CompanionOutput)" DestinationFolder="$(OutDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using WindowsToolbox.Core;
|
||||
using WindowsToolbox.Infrastructure;
|
||||
|
||||
if (args.Length >= 3 &&
|
||||
args[0].Equals("desktop-archive", StringComparison.OrdinalIgnoreCase) &&
|
||||
args[1].Equals("--profile", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var dispatcher = new ToolboxCommandDispatcher();
|
||||
var result = await dispatcher.ExecuteAsync(new ToolboxCommand
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Kind = ToolboxCommandKind.ArchiveDesktop,
|
||||
Arguments = new Dictionary<string, string> { ["profile"] = args[2] }
|
||||
});
|
||||
Console.WriteLine(result.Summary);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("用法: WindowsToolbox.Cli desktop-archive --profile <id>");
|
||||
return 2;
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WindowsToolbox.Core\WindowsToolbox.Core.csproj" />
|
||||
<ProjectReference Include="..\WindowsToolbox.Infrastructure\WindowsToolbox.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WindowsToolbox.Core;
|
||||
|
||||
public enum ToolboxCommandKind
|
||||
{
|
||||
DetectStatus,
|
||||
EnableClassicContextMenu,
|
||||
RestoreContextMenu,
|
||||
HideShortcutArrow,
|
||||
RestoreShortcutArrow,
|
||||
PauseWindowsUpdate,
|
||||
DisableWindowsUpdate,
|
||||
RestoreWindowsUpdate,
|
||||
ConfigureUpdateRenewal,
|
||||
DisableDefenderProtection,
|
||||
RestoreDefenderProtection,
|
||||
PrepareAutoLogon,
|
||||
RestoreAutoLogonPreparation,
|
||||
ArchiveDesktop,
|
||||
UndoDesktopArchive,
|
||||
ConfigureDesktopSchedule,
|
||||
ActivateWindowsKms,
|
||||
RestoreKmsConfiguration
|
||||
}
|
||||
|
||||
public sealed record ToolboxCommand
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required ToolboxCommandKind Kind { get; init; }
|
||||
public Dictionary<string, string> Arguments { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now;
|
||||
public string? ResultPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CommandStepResult(
|
||||
string Name,
|
||||
bool Success,
|
||||
string Message,
|
||||
bool RequiresRestart = false);
|
||||
|
||||
public sealed record CommandResult
|
||||
{
|
||||
public required string CommandId { get; init; }
|
||||
public required ToolboxCommandKind Kind { get; init; }
|
||||
public bool Success { get; init; }
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public List<CommandStepResult> Steps { get; init; } = [];
|
||||
public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
public sealed record SystemStatus
|
||||
{
|
||||
public string ProductName { get; init; } = "Windows";
|
||||
public string EditionId { get; init; } = "Unknown";
|
||||
public string DisplayVersion { get; init; } = "Unknown";
|
||||
public string Build { get; init; } = "Unknown";
|
||||
public bool IsWindows11 { get; init; }
|
||||
public bool IsAdministrator { get; init; }
|
||||
public bool ClassicContextMenuEnabled { get; init; }
|
||||
public bool ShortcutArrowHidden { get; init; }
|
||||
public string UpdateMode { get; init; } = "检测中";
|
||||
public DateTimeOffset? UpdatePauseUntil { get; init; }
|
||||
public bool? DefenderRealtimeProtectionEnabled { get; init; }
|
||||
public bool? TamperProtectionEnabled { get; init; }
|
||||
public bool AutoLogonOptionPrepared { get; init; }
|
||||
public string ActivationStatus { get; init; } = "未知";
|
||||
public string? PartialProductKey { get; init; }
|
||||
public string? KmsHost { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RegistryValueSnapshot
|
||||
{
|
||||
public required string Hive { get; init; }
|
||||
public required string KeyPath { get; init; }
|
||||
public required string ValueName { get; init; }
|
||||
public bool Exists { get; init; }
|
||||
public int Kind { get; init; }
|
||||
public string? TextValue { get; init; }
|
||||
public string[]? TextValues { get; init; }
|
||||
public byte[]? BinaryValue { get; init; }
|
||||
public long? NumericValue { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FeatureSnapshot
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Feature { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now;
|
||||
public List<RegistryValueSnapshot> RegistryValues { get; init; } = [];
|
||||
public Dictionary<string, string?> Metadata { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record DesktopArchiveProfile
|
||||
{
|
||||
public string Id { get; init; } = "default";
|
||||
public required string DestinationRoot { get; init; }
|
||||
public bool ExcludeShortcuts { get; init; } = true;
|
||||
public bool RenameOnCollision { get; init; } = true;
|
||||
public int MonthlyDay { get; init; } = 1;
|
||||
public TimeOnly RunAt { get; init; } = new(9, 0);
|
||||
}
|
||||
|
||||
public sealed record ArchivedFile(string OriginalPath, string ArchivedPath, long Length, bool IsDirectory = false);
|
||||
|
||||
public sealed record ArchiveManifest
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ProfileId { get; init; }
|
||||
public required string DestinationFolder { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now;
|
||||
public List<ArchivedFile> Files { get; init; } = [];
|
||||
public List<string> Skipped { get; init; } = [];
|
||||
public List<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record KmsKeyEntry(
|
||||
string EditionId,
|
||||
string DisplayName,
|
||||
string ProductKey,
|
||||
string AppliesTo,
|
||||
bool Supported = true);
|
||||
|
||||
public sealed record OperationLogEntry
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Feature { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required string Summary { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.Now;
|
||||
public List<CommandStepResult> Steps { get; init; } = [];
|
||||
}
|
||||
|
||||
public static class JsonDefaults
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
}
|
||||
|
||||
public static class ToolboxPaths
|
||||
{
|
||||
public static string UserRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"WindowsToolbox");
|
||||
|
||||
public static string MachineRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"WindowsToolbox");
|
||||
|
||||
public static string PendingCommands => Path.Combine(UserRoot, "Pending");
|
||||
public static string Profiles => Path.Combine(UserRoot, "Profiles");
|
||||
public static string Manifests => Path.Combine(UserRoot, "ArchiveManifests");
|
||||
public static string Logs => Path.Combine(UserRoot, "Logs");
|
||||
public static string Snapshots => Path.Combine(MachineRoot, "Snapshots");
|
||||
public static string Assets => Path.Combine(MachineRoot, "Assets");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,74 @@
|
||||
using WindowsToolbox.Core;
|
||||
using WindowsToolbox.Infrastructure;
|
||||
|
||||
try
|
||||
{
|
||||
if (!SystemStatusService.IsAdministrator())
|
||||
{
|
||||
Console.Error.WriteLine("WindowsToolbox.ElevatedHost 必须以管理员身份运行。");
|
||||
return 5;
|
||||
}
|
||||
|
||||
var dispatcher = new ToolboxCommandDispatcher();
|
||||
ToolboxCommand command;
|
||||
|
||||
if (args.Length == 2 && args[0].Equals("--command-file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = ValidatePendingPath(args[1]);
|
||||
command = await JsonFileStore.LoadAsync<ToolboxCommand>(path)
|
||||
?? throw new InvalidDataException("命令文件为空或格式无效");
|
||||
if (DateTimeOffset.Now - command.CreatedAt > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
throw new InvalidOperationException("命令文件已过期");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.ResultPath))
|
||||
{
|
||||
_ = ValidatePendingPath(command.ResultPath);
|
||||
}
|
||||
}
|
||||
else if (args.Length == 1 && args[0].Equals("--scheduled-update-disable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
command = new ToolboxCommand
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Kind = ToolboxCommandKind.DisableWindowsUpdate,
|
||||
Arguments = new Dictionary<string, string> { ["guard"] = "true" }
|
||||
};
|
||||
}
|
||||
else if (args.Length == 2 && args[0].Equals("--scheduled-update-renew", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
command = new ToolboxCommand
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Kind = ToolboxCommandKind.PauseWindowsUpdate,
|
||||
Arguments = new Dictionary<string, string> { ["days"] = args[1] }
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("用法: --command-file <path> | --scheduled-update-disable | --scheduled-update-renew <days>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var result = await dispatcher.ExecuteAsync(command);
|
||||
Console.WriteLine(result.Summary);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static string ValidatePendingPath(string path)
|
||||
{
|
||||
var root = Path.GetFullPath(ToolboxPaths.PendingCommands).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException("命令文件必须位于 WindowsToolbox Pending 目录中");
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WindowsToolbox.Core\WindowsToolbox.Core.csproj" />
|
||||
<ProjectReference Include="..\WindowsToolbox.Infrastructure\WindowsToolbox.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\data\kms-keys.json" Link="data\kms-keys.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="WindowsToolbox.ElevatedHost" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{4f476546-937d-4f42-b2d6-a2111a7e8f8d}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed partial class ActivationService(
|
||||
RegistryStore registry,
|
||||
SnapshotStore snapshots,
|
||||
ProcessRunner processes)
|
||||
{
|
||||
private const string Feature = "kms-configuration";
|
||||
private const string SppKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\SoftwareProtectionPlatform";
|
||||
|
||||
public IReadOnlyList<KmsKeyEntry> LoadCatalog()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "data", "kms-keys.json"),
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "data", "kms-keys.json"))
|
||||
};
|
||||
foreach (var path in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<List<KmsKeyEntry>>(File.ReadAllText(path), JsonDefaults.Options) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to the next catalog location.
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ActivateAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var edition = command.Arguments.GetValueOrDefault("edition") ?? throw new ArgumentException("请选择 Windows 版本");
|
||||
var host = command.Arguments.GetValueOrDefault("host")?.Trim() ?? throw new ArgumentException("请输入本地 KMS 主机地址");
|
||||
var port = int.TryParse(command.Arguments.GetValueOrDefault("port"), out var parsedPort) ? parsedPort : 1688;
|
||||
ValidateHost(host, port);
|
||||
|
||||
var entry = LoadCatalog().FirstOrDefault(x => x.EditionId.Equals(edition, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("密钥目录中没有所选版本");
|
||||
if (!entry.Supported || !ProductKeyRegex().IsMatch(entry.ProductKey))
|
||||
{
|
||||
throw new InvalidOperationException("所选 Windows 版本不支持 KMS 客户端激活");
|
||||
}
|
||||
|
||||
var reachable = await TestPortAsync(host, port, cancellationToken);
|
||||
steps.Add(new CommandStepResult("KMS 连接", reachable,
|
||||
reachable ? $"已连接 {host}:{port}" : $"无法连接 {host}:{port},没有更改产品密钥"));
|
||||
if (!reachable)
|
||||
{
|
||||
return Build(command, steps, "本地 KMS 服务器不可达");
|
||||
}
|
||||
|
||||
await snapshots.GetOrCreateAsync(Feature, () => new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = Feature,
|
||||
RegistryValues =
|
||||
[
|
||||
registry.Capture("HKLM", SppKey, "KeyManagementServiceName"),
|
||||
registry.Capture("HKLM", SppKey, "KeyManagementServicePort")
|
||||
],
|
||||
Metadata = { ["PreviousKeyRecoverable"] = "False" }
|
||||
}, cancellationToken);
|
||||
|
||||
var install = await RunSlmgrAsync("/ipk", entry.ProductKey, cancellationToken);
|
||||
steps.Add(new CommandStepResult("安装 GVLK", install.Success,
|
||||
install.Success ? $"已安装 {entry.DisplayName} 客户端密钥" : Friendly(install, "安装密钥失败")));
|
||||
if (!install.Success)
|
||||
{
|
||||
return Build(command, steps, "KMS 客户端密钥安装失败");
|
||||
}
|
||||
|
||||
var endpoint = FormatEndpoint(host, port);
|
||||
var setHost = await RunSlmgrAsync("/skms", endpoint, cancellationToken);
|
||||
steps.Add(new CommandStepResult("设置 KMS 主机", setHost.Success,
|
||||
setHost.Success ? $"已设置 {endpoint}" : Friendly(setHost, "设置 KMS 主机失败")));
|
||||
if (!setHost.Success)
|
||||
{
|
||||
return Build(command, steps, "KMS 主机设置失败");
|
||||
}
|
||||
|
||||
var activate = await RunSlmgrAsync("/ato", null, cancellationToken);
|
||||
steps.Add(new CommandStepResult("执行激活", activate.Success,
|
||||
Friendly(activate, activate.Success ? "激活命令已完成" : "激活失败")));
|
||||
var verify = await processes.RunPowerShellAsync(
|
||||
"$p=Get-CimInstance SoftwareLicensingProduct -Filter \"Name like 'Windows%'\" | " +
|
||||
"Where-Object {$_.PartialProductKey} | Select-Object -First 1; [int]$p.LicenseStatus",
|
||||
cancellationToken);
|
||||
var licensed = verify.Success && verify.StandardOutput
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(line => line == "1");
|
||||
steps.Add(new CommandStepResult("激活状态验证", licensed,
|
||||
licensed ? "Windows 报告许可证状态为已激活" : "激活命令结束,但 Windows 尚未报告已激活状态"));
|
||||
return Build(command, steps, licensed ? "Windows KMS 激活成功" : "Windows KMS 激活未通过状态验证");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("KMS 激活", false, ex.Message));
|
||||
return Build(command, steps, "KMS 激活失败");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(Feature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
var clear = await RunSlmgrAsync("/ckms", null, cancellationToken);
|
||||
steps.Add(new CommandStepResult("清除 KMS 主机", clear.Success, Friendly(clear, "已请求清除 KMS 主机")));
|
||||
return Build(command, steps, "没有基线快照;已清除 KMS 主机配置");
|
||||
}
|
||||
|
||||
var hostValue = snapshot.RegistryValues.First(x => x.ValueName == "KeyManagementServiceName");
|
||||
var portValue = snapshot.RegistryValues.First(x => x.ValueName == "KeyManagementServicePort");
|
||||
ProcessResult result;
|
||||
if (hostValue.Exists && !string.IsNullOrWhiteSpace(hostValue.TextValue))
|
||||
{
|
||||
var oldPort = portValue.NumericValue?.ToString() ?? portValue.TextValue ?? "1688";
|
||||
result = await RunSlmgrAsync("/skms", FormatEndpoint(hostValue.TextValue, int.TryParse(oldPort, out var parsed) ? parsed : 1688), cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await RunSlmgrAsync("/ckms", null, cancellationToken);
|
||||
}
|
||||
|
||||
steps.Add(new CommandStepResult("恢复 KMS 主机", result.Success,
|
||||
Friendly(result, result.Success ? "KMS 主机配置已恢复" : "KMS 主机配置恢复失败")));
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
|
||||
steps.Add(new CommandStepResult("产品密钥", true,
|
||||
"KMS 主机已恢复;Windows 不提供原完整产品密钥,因此产品密钥本身不会自动还原"));
|
||||
if (result.Success)
|
||||
{
|
||||
snapshots.Delete(Feature);
|
||||
}
|
||||
|
||||
return Build(command, steps, result.Success ? "KMS 主机配置已恢复" : "KMS 配置恢复失败");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("恢复 KMS", false, ex.Message));
|
||||
return Build(command, steps, "恢复 KMS 配置失败");
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ProcessResult> RunSlmgrAsync(string option, string? value, CancellationToken cancellationToken)
|
||||
{
|
||||
var script = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32", "slmgr.vbs");
|
||||
var args = new List<string> { "//NoLogo", script, option };
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
args.Add(value);
|
||||
}
|
||||
return processes.RunAsync("cscript.exe", args, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> TestPortAsync(string host, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(4));
|
||||
await client.ConnectAsync(host, port, timeout.Token);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateHost(string host, int port)
|
||||
{
|
||||
if (host.Length is < 1 or > 255 || !HostRegex().IsMatch(host))
|
||||
{
|
||||
throw new ArgumentException("KMS 主机只能是 IP 地址或由字母、数字、点、冒号和连字符组成的主机名");
|
||||
}
|
||||
|
||||
if (port is < 1 or > 65535)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(port), "KMS 端口必须在 1 到 65535 之间");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatEndpoint(string host, int port) =>
|
||||
host.StartsWith("[", StringComparison.Ordinal) && host.EndsWith("]", StringComparison.Ordinal)
|
||||
? $"{host}:{port}"
|
||||
: host.Contains(':', StringComparison.Ordinal)
|
||||
? $"[{host}]:{port}"
|
||||
: $"{host}:{port}";
|
||||
|
||||
private static CommandResult Build(ToolboxCommand command, List<CommandStepResult> steps, string summary) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = steps.Count > 0 && steps.All(x => x.Success),
|
||||
Summary = summary,
|
||||
Steps = steps
|
||||
};
|
||||
|
||||
private static string Friendly(ProcessResult result, string fallback) =>
|
||||
string.IsNullOrWhiteSpace(result.CombinedOutput) ? fallback : result.CombinedOutput.Trim();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9.:-]{1,255}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex HostRegex();
|
||||
|
||||
[GeneratedRegex(@"^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ProductKeyRegex();
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class AppearanceService(RegistryStore registry, SnapshotStore snapshots)
|
||||
{
|
||||
private const string ClassicFeature = "classic-context-menu";
|
||||
private const string ClassicKey = @"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}";
|
||||
private const string ClassicServerKey = ClassicKey + @"\InprocServer32";
|
||||
private const string AtlasRootKey = @"SOFTWARE\AtlasOS";
|
||||
private const string AtlasContextMenuKey = @"SOFTWARE\AtlasOS\Services\OldContextMenu";
|
||||
private const string ArrowFeature = "shortcut-arrow";
|
||||
private const string ShellIconsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Icons";
|
||||
|
||||
public async Task<CommandResult> EnableClassicContextMenuAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await snapshots.GetOrCreateAsync(ClassicFeature, () =>
|
||||
{
|
||||
var atlasPresent = registry.KeyExists("HKLM", AtlasRootKey);
|
||||
var snapshot = new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = ClassicFeature,
|
||||
RegistryValues = [registry.Capture("HKCU", ClassicServerKey, string.Empty)],
|
||||
Metadata =
|
||||
{
|
||||
["RootKeyExisted"] = registry.KeyExists("HKCU", ClassicKey).ToString(),
|
||||
["AtlasPresent"] = atlasPresent.ToString()
|
||||
}
|
||||
};
|
||||
if (atlasPresent)
|
||||
{
|
||||
snapshot.RegistryValues.Add(registry.Capture("HKLM", AtlasContextMenuKey, "state"));
|
||||
}
|
||||
return snapshot;
|
||||
}, cancellationToken);
|
||||
|
||||
registry.Set("HKCU", ClassicServerKey, string.Empty, string.Empty, RegistryValueKind.String);
|
||||
if (registry.KeyExists("HKLM", AtlasRootKey))
|
||||
{
|
||||
registry.Set("HKLM", AtlasContextMenuKey, "state", 1, RegistryValueKind.DWord);
|
||||
}
|
||||
var verified = registry.KeyExists("HKCU", ClassicServerKey);
|
||||
return Result(command, verified, verified ? "经典右键菜单已启用" : "注册表写入后验证失败", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreContextMenuAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(ClassicFeature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
registry.DeleteKeyTree("HKCU", ClassicKey);
|
||||
if (registry.KeyExists("HKLM", AtlasRootKey))
|
||||
{
|
||||
registry.Set("HKLM", AtlasContextMenuKey, "state", 0, RegistryValueKind.DWord);
|
||||
}
|
||||
}
|
||||
else if (snapshot.Metadata.TryGetValue("RootKeyExisted", out var existed) &&
|
||||
bool.TryParse(existed, out var rootExisted) && !rootExisted)
|
||||
{
|
||||
registry.DeleteKeyTree("HKCU", ClassicKey);
|
||||
foreach (var value in snapshot.RegistryValues.Where(value => value.Hive.Equals("HKLM", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots.Delete(ClassicFeature);
|
||||
return Result(command, true, "右键菜单设置已恢复", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> HideShortcutArrowAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await snapshots.GetOrCreateAsync(ArrowFeature, () => new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = ArrowFeature,
|
||||
RegistryValues = [registry.Capture("HKLM", ShellIconsKey, "29")]
|
||||
}, cancellationToken);
|
||||
|
||||
Directory.CreateDirectory(ToolboxPaths.Assets);
|
||||
var iconPath = Path.Combine(ToolboxPaths.Assets, "blank.ico");
|
||||
if (!File.Exists(iconPath))
|
||||
{
|
||||
File.WriteAllBytes(iconPath, CreateTransparentIcon());
|
||||
}
|
||||
|
||||
registry.Set("HKLM", ShellIconsKey, "29", $"{iconPath},0", RegistryValueKind.String);
|
||||
var actual = registry.Read("HKLM", ShellIconsKey, "29")?.ToString();
|
||||
var verified = string.Equals(actual, $"{iconPath},0", StringComparison.OrdinalIgnoreCase);
|
||||
return Result(command, verified, verified ? "快捷方式箭头已隐藏" : "图标覆盖写入后验证失败", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreShortcutArrowAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(ArrowFeature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
registry.DeleteValue("HKLM", ShellIconsKey, "29");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots.Delete(ArrowFeature);
|
||||
return Result(command, true, "快捷方式箭头设置已恢复", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult Result(ToolboxCommand command, bool success, string message, bool restart = false) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = success,
|
||||
Summary = message,
|
||||
Steps = [new CommandStepResult("外观设置", success, message, restart)]
|
||||
};
|
||||
|
||||
private static byte[] CreateTransparentIcon() =>
|
||||
[
|
||||
0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x20, 0x00,
|
||||
0x30, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00,
|
||||
0x28, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x02, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x20, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x08, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0x00, 0x00, 0x00
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class AutoLogonService(RegistryStore registry, SnapshotStore snapshots)
|
||||
{
|
||||
private const string Feature = "auto-logon-option";
|
||||
private const string Key = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device";
|
||||
private const string ValueName = "DevicePasswordLessBuildVersion";
|
||||
|
||||
public async Task<CommandResult> PrepareAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await snapshots.GetOrCreateAsync(Feature, () => new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = Feature,
|
||||
RegistryValues = [registry.Capture("HKLM", Key, ValueName)]
|
||||
}, cancellationToken);
|
||||
registry.Set("HKLM", Key, ValueName, 0, RegistryValueKind.DWord);
|
||||
var prepared = Convert.ToInt32(registry.Read("HKLM", Key, ValueName) ?? 1) == 0;
|
||||
return Build(command, prepared, prepared
|
||||
? "自动登录选项已准备;请在随后打开的账户窗口中完成设置"
|
||||
: "自动登录选项注册表验证失败");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Build(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(Feature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
registry.DeleteValue("HKLM", Key, ValueName);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots.Delete(Feature);
|
||||
return Build(command, true, "自动登录选项准备设置已恢复;账户本身的自动登录设置请在 netplwiz 中修改");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Build(command, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void OpenNetplwiz() => Process.Start(new ProcessStartInfo("netplwiz.exe") { UseShellExecute = true });
|
||||
|
||||
private static CommandResult Build(ToolboxCommand command, bool success, string summary) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = success,
|
||||
Summary = summary,
|
||||
Steps = [new CommandStepResult("自动登录", success, summary)]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public static class CompanionLocator
|
||||
{
|
||||
public static string Find(string fileName)
|
||||
{
|
||||
var adjacent = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (File.Exists(adjacent))
|
||||
{
|
||||
return adjacent;
|
||||
}
|
||||
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
for (var i = 0; i < 7 && directory is not null; i++, directory = directory.Parent)
|
||||
{
|
||||
var candidates = Directory.Exists(directory.FullName)
|
||||
? Directory.EnumerateFiles(directory.FullName, fileName, SearchOption.AllDirectories)
|
||||
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}.tools{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||
.Take(1)
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
if (candidates.Length > 0)
|
||||
{
|
||||
return candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"没有找到配套程序 {fileName}。请重新构建或安装完整工具箱。", fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class DesktopArchiveService
|
||||
{
|
||||
private readonly TaskSchedulerService _taskScheduler;
|
||||
private readonly Func<string> _desktopPathProvider;
|
||||
private readonly string _profilesRoot;
|
||||
private readonly string _manifestsRoot;
|
||||
|
||||
private static readonly HashSet<string> ExcludedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".lnk", ".url"
|
||||
};
|
||||
|
||||
public DesktopArchiveService(
|
||||
TaskSchedulerService taskScheduler,
|
||||
Func<string>? desktopPathProvider = null,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
_taskScheduler = taskScheduler;
|
||||
_desktopPathProvider = desktopPathProvider ?? (() => KnownFolderPath(FolderIds.Desktop));
|
||||
_profilesRoot = dataRoot is null ? ToolboxPaths.Profiles : Path.Combine(dataRoot, "Profiles");
|
||||
_manifestsRoot = dataRoot is null ? ToolboxPaths.Manifests : Path.Combine(dataRoot, "ArchiveManifests");
|
||||
}
|
||||
|
||||
public string GetDesktopPath() => _desktopPathProvider();
|
||||
|
||||
public async Task SaveProfileAsync(DesktopArchiveProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateProfile(profile);
|
||||
await JsonFileStore.SaveAsync(ProfilePath(profile.Id), profile, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<DesktopArchiveProfile?> LoadProfileAsync(string id, CancellationToken cancellationToken = default) =>
|
||||
JsonFileStore.LoadAsync<DesktopArchiveProfile>(ProfilePath(id), cancellationToken);
|
||||
|
||||
public Task<CommandResult> ArchiveAsync(ToolboxCommand command, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() => ArchiveCoreAsync(command, cancellationToken), cancellationToken);
|
||||
|
||||
private async Task<CommandResult> ArchiveCoreAsync(ToolboxCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await ResolveProfileAsync(command, cancellationToken);
|
||||
ValidateProfile(profile);
|
||||
var desktop = Path.GetFullPath(GetDesktopPath());
|
||||
var destinationRoot = Path.GetFullPath(profile.DestinationRoot);
|
||||
if (IsSameOrChildPath(destinationRoot, desktop))
|
||||
{
|
||||
throw new InvalidOperationException("归档目标不能位于桌面目录内部,否则可能把归档目录再次移动");
|
||||
}
|
||||
var destination = Path.Combine(destinationRoot, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
Directory.CreateDirectory(destination);
|
||||
|
||||
var manifest = new ArchiveManifest
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
ProfileId = profile.Id,
|
||||
DestinationFolder = destination
|
||||
};
|
||||
var manifestPath = Path.Combine(_manifestsRoot, $"{manifest.CreatedAt:yyyyMMdd-HHmmssfffffff}-{manifest.Id}.json");
|
||||
await JsonFileStore.SaveAsync(manifestPath, manifest, cancellationToken);
|
||||
|
||||
foreach (var source in Directory.EnumerateFileSystemEntries(desktop, "*", SearchOption.TopDirectoryOnly).ToArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName(source);
|
||||
var attributes = File.GetAttributes(source);
|
||||
var isDirectory = attributes.HasFlag(FileAttributes.Directory);
|
||||
if (name.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase) ||
|
||||
!isDirectory && profile.ExcludeShortcuts && ExcludedExtensions.Contains(Path.GetExtension(source)) ||
|
||||
attributes.HasFlag(FileAttributes.System) ||
|
||||
isDirectory && attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
manifest.Skipped.Add(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = Path.Combine(destination, name);
|
||||
if (File.Exists(target) || Directory.Exists(target))
|
||||
{
|
||||
if (!profile.RenameOnCollision)
|
||||
{
|
||||
manifest.Skipped.Add(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
target = GetUniquePath(target, isDirectory);
|
||||
}
|
||||
|
||||
var length = isDirectory ? 0 : new FileInfo(source).Length;
|
||||
if (isDirectory)
|
||||
{
|
||||
MoveDirectory(source, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(source, target);
|
||||
}
|
||||
manifest.Files.Add(new ArchivedFile(source, target, length, isDirectory));
|
||||
await JsonFileStore.SaveAsync(manifestPath, manifest, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
manifest.Errors.Add($"{source}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await JsonFileStore.SaveAsync(manifestPath, manifest, cancellationToken);
|
||||
var success = manifest.Errors.Count == 0;
|
||||
var summary = $"已移动 {manifest.Files.Count} 个项目,跳过 {manifest.Skipped.Count} 个,失败 {manifest.Errors.Count} 个";
|
||||
return new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = success,
|
||||
Summary = summary,
|
||||
Steps =
|
||||
[
|
||||
new CommandStepResult("桌面归档", success, summary),
|
||||
new CommandStepResult("恢复清单", true, manifestPath)
|
||||
]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failure(command, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<CommandResult> UndoLatestAsync(ToolboxCommand command, CancellationToken cancellationToken = default) =>
|
||||
Task.Run(() => UndoLatestCoreAsync(command, cancellationToken), cancellationToken);
|
||||
|
||||
private async Task<CommandResult> UndoLatestCoreAsync(ToolboxCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_manifestsRoot))
|
||||
{
|
||||
return Failure(command, "没有可恢复的桌面归档记录");
|
||||
}
|
||||
|
||||
var path = Directory.EnumerateFiles(_manifestsRoot, "*.json")
|
||||
.OrderByDescending(Path.GetFileName)
|
||||
.FirstOrDefault();
|
||||
if (path is null)
|
||||
{
|
||||
return Failure(command, "没有可恢复的桌面归档记录");
|
||||
}
|
||||
|
||||
var manifest = await JsonFileStore.LoadAsync<ArchiveManifest>(path, cancellationToken)
|
||||
?? throw new InvalidDataException("归档恢复清单损坏");
|
||||
var restored = 0;
|
||||
var errors = new List<string>();
|
||||
foreach (var item in manifest.Files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = item.IsDirectory ? Directory.Exists(item.ArchivedPath) : File.Exists(item.ArchivedPath);
|
||||
if (!exists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetDirectory = Path.GetDirectoryName(item.OriginalPath)!;
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
var occupied = File.Exists(item.OriginalPath) || Directory.Exists(item.OriginalPath);
|
||||
var target = occupied ? GetUniquePath(item.OriginalPath, item.IsDirectory) : item.OriginalPath;
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
MoveDirectory(item.ArchivedPath, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(item.ArchivedPath, target);
|
||||
}
|
||||
restored++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{item.ArchivedPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
var summary = $"已恢复 {restored} 个项目,失败 {errors.Count} 个";
|
||||
return new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = errors.Count == 0,
|
||||
Summary = summary,
|
||||
Steps = [new CommandStepResult("恢复桌面文件", errors.Count == 0, summary)]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failure(command, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ConfigureScheduleAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var enabled = command.Arguments.TryGetValue("enabled", out var value) && bool.Parse(value);
|
||||
var profile = await ResolveProfileAsync(command, cancellationToken);
|
||||
await SaveProfileAsync(profile, cancellationToken);
|
||||
var taskName = $"DesktopArchive-{profile.Id}";
|
||||
if (enabled)
|
||||
{
|
||||
var cli = CompanionLocator.Find("WindowsToolbox.Cli.exe");
|
||||
_taskScheduler.RegisterMonthlyTask(taskName, cli, $"desktop-archive --profile {profile.Id}", profile.MonthlyDay, profile.RunAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_taskScheduler.DeleteToolboxTask(taskName);
|
||||
}
|
||||
|
||||
var summary = enabled ? "桌面归档计划任务已创建" : "桌面归档计划任务已删除";
|
||||
return new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = true,
|
||||
Summary = summary,
|
||||
Steps = [new CommandStepResult("计划任务", true, summary)]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failure(command, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateProfile(DesktopArchiveProfile profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile.DestinationRoot))
|
||||
{
|
||||
throw new ArgumentException("请选择归档目标文件夹");
|
||||
}
|
||||
|
||||
if (profile.MonthlyDay is < 1 or > 28)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(profile.MonthlyDay), "每月执行日期必须在 1 到 28 之间");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DesktopArchiveProfile> ResolveProfileAsync(ToolboxCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var profileId = command.Arguments.GetValueOrDefault("profile", "default");
|
||||
var saved = await JsonFileStore.LoadAsync<DesktopArchiveProfile>(ProfilePath(profileId), cancellationToken);
|
||||
if (saved is not null && !command.Arguments.ContainsKey("destination"))
|
||||
{
|
||||
return saved;
|
||||
}
|
||||
|
||||
var destination = command.Arguments.GetValueOrDefault("destination")
|
||||
?? throw new ArgumentException("缺少桌面归档目标文件夹");
|
||||
var day = int.TryParse(command.Arguments.GetValueOrDefault("day"), out var parsedDay) ? parsedDay : 1;
|
||||
var runAt = TimeOnly.TryParse(command.Arguments.GetValueOrDefault("time"), out var parsedTime)
|
||||
? parsedTime
|
||||
: new TimeOnly(9, 0);
|
||||
return new DesktopArchiveProfile
|
||||
{
|
||||
Id = profileId,
|
||||
DestinationRoot = destination,
|
||||
MonthlyDay = day,
|
||||
RunAt = runAt
|
||||
};
|
||||
}
|
||||
|
||||
private string ProfilePath(string id) => Path.Combine(_profilesRoot, $"desktop-{id}.json");
|
||||
|
||||
private static string GetUniquePath(string path, bool isDirectory = false)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var name = isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path);
|
||||
var extension = isDirectory ? string.Empty : Path.GetExtension(path);
|
||||
for (var index = 1; index < int.MaxValue; index++)
|
||||
{
|
||||
var candidate = Path.Combine(directory, $"{name} ({index}){extension}");
|
||||
if (!File.Exists(candidate) && !Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException($"无法为 {path} 生成不冲突的文件名");
|
||||
}
|
||||
|
||||
private static void MoveDirectory(string source, string target)
|
||||
{
|
||||
var sourceRoot = Path.GetPathRoot(Path.GetFullPath(source));
|
||||
var targetRoot = Path.GetPathRoot(Path.GetFullPath(target));
|
||||
if (string.Equals(sourceRoot, targetRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Directory.Move(source, target);
|
||||
return;
|
||||
}
|
||||
|
||||
Microsoft.VisualBasic.FileIO.FileSystem.MoveDirectory(source, target);
|
||||
}
|
||||
|
||||
private static bool IsSameOrChildPath(string candidate, string parent)
|
||||
{
|
||||
var normalizedCandidate = Path.GetFullPath(candidate).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var normalizedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return normalizedCandidate.Equals(normalizedParent, StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedCandidate.StartsWith(normalizedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static CommandResult Failure(ToolboxCommand command, string message) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = false,
|
||||
Summary = message,
|
||||
Steps = [new CommandStepResult("桌面归档", false, message)]
|
||||
};
|
||||
|
||||
private static string KnownFolderPath(Guid id)
|
||||
{
|
||||
var result = SHGetKnownFolderPath(id, 0, IntPtr.Zero, out var pointer);
|
||||
if (result != 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(result);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStringUni(pointer)
|
||||
?? Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
private static extern int SHGetKnownFolderPath(
|
||||
[MarshalAs(UnmanagedType.LPStruct)] Guid rfid,
|
||||
uint flags,
|
||||
IntPtr token,
|
||||
out IntPtr path);
|
||||
|
||||
private static class FolderIds
|
||||
{
|
||||
public static readonly Guid Desktop = new("B4BFCC3A-DB2C-424C-B029-7FE99A87C641");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class ElevatedCommandClient
|
||||
{
|
||||
private readonly ToolboxCommandDispatcher _dispatcher = new();
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(
|
||||
ToolboxCommandKind kind,
|
||||
Dictionary<string, string>? arguments = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
if (!ToolboxCommandDispatcher.RequiresElevation(kind) || SystemStatusService.IsAdministrator())
|
||||
{
|
||||
return await _dispatcher.ExecuteAsync(new ToolboxCommand
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Arguments = arguments ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(ToolboxPaths.PendingCommands);
|
||||
var commandPath = Path.Combine(ToolboxPaths.PendingCommands, $"{id}.command.json");
|
||||
var resultPath = Path.Combine(ToolboxPaths.PendingCommands, $"{id}.result.json");
|
||||
var command = new ToolboxCommand
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Arguments = arguments ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
|
||||
ResultPath = resultPath
|
||||
};
|
||||
await JsonFileStore.SaveAsync(commandPath, command, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var host = CompanionLocator.Find("WindowsToolbox.ElevatedHost.exe");
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = host,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas",
|
||||
Arguments = $"--command-file \"{commandPath}\"",
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
if (process is null)
|
||||
{
|
||||
throw new InvalidOperationException("无法启动管理员执行器");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
var result = await JsonFileStore.LoadAsync<CommandResult>(resultPath, cancellationToken);
|
||||
return result ?? new CommandResult
|
||||
{
|
||||
CommandId = id,
|
||||
Kind = kind,
|
||||
Success = false,
|
||||
Summary = $"管理员执行器没有返回结果(退出代码 {process.ExitCode})",
|
||||
Steps = [new CommandStepResult("管理员执行器", false, "没有结果文件")]
|
||||
};
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
CommandId = id,
|
||||
Kind = kind,
|
||||
Success = false,
|
||||
Summary = "已取消管理员授权",
|
||||
Steps = [new CommandStepResult("管理员授权", false, "用户取消了 UAC 提示")]
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(commandPath);
|
||||
TryDelete(resultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Pending files are harmless and overwritten only by unique id.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public static class JsonFileStore
|
||||
{
|
||||
public static async Task SaveAsync<T>(string path, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(value, JsonDefaults.Options);
|
||||
var temporaryPath = path + ".tmp";
|
||||
await File.WriteAllTextAsync(temporaryPath, json, cancellationToken);
|
||||
File.Move(temporaryPath, path, true);
|
||||
}
|
||||
|
||||
public static async Task<T?> LoadAsync<T>(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, JsonDefaults.Options, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OperationLogStore
|
||||
{
|
||||
public async Task AddAsync(OperationLogEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Combine(ToolboxPaths.Logs, $"{entry.Timestamp:yyyyMMdd-HHmmssfffffff}-{entry.Id}.json");
|
||||
await JsonFileStore.SaveAsync(path, entry, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OperationLogEntry>> ReadRecentAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Directory.Exists(ToolboxPaths.Logs))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var entries = new List<OperationLogEntry>();
|
||||
foreach (var path in Directory.EnumerateFiles(ToolboxPaths.Logs, "*.json")
|
||||
.OrderByDescending(Path.GetFileName)
|
||||
.Take(limit))
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await JsonFileStore.LoadAsync<OperationLogEntry>(path, cancellationToken);
|
||||
if (entry is not null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A damaged history entry must not prevent the application from starting.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A log can be temporarily locked by the elevated host.
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
|
||||
{
|
||||
public bool Success => ExitCode == 0;
|
||||
public string CombinedOutput => string.Join(Environment.NewLine,
|
||||
new[] { StandardOutput.Trim(), StandardError.Trim() }.Where(x => x.Length > 0));
|
||||
}
|
||||
|
||||
public sealed class ProcessRunner
|
||||
{
|
||||
public async Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IEnumerable<string> arguments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
||||
var errorTask = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Preserve the original cancellation.
|
||||
}
|
||||
throw;
|
||||
}
|
||||
return new ProcessResult(process.ExitCode, await outputTask, await errorTask);
|
||||
}
|
||||
|
||||
public Task<ProcessResult> RunPowerShellAsync(string script, CancellationToken cancellationToken = default) =>
|
||||
RunAsync("powershell.exe",
|
||||
["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class RegistryStore
|
||||
{
|
||||
public RegistryValueSnapshot Capture(string hive, string keyPath, string valueName)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: false);
|
||||
using var key = baseKey.OpenSubKey(keyPath, writable: false);
|
||||
if (key is null || !key.GetValueNames().Contains(valueName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return new RegistryValueSnapshot
|
||||
{
|
||||
Hive = hive,
|
||||
KeyPath = keyPath,
|
||||
ValueName = valueName,
|
||||
Exists = false
|
||||
};
|
||||
}
|
||||
|
||||
var kind = key.GetValueKind(valueName);
|
||||
var value = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
|
||||
var snapshot = new RegistryValueSnapshot
|
||||
{
|
||||
Hive = hive,
|
||||
KeyPath = keyPath,
|
||||
ValueName = valueName,
|
||||
Exists = true,
|
||||
Kind = (int)kind
|
||||
};
|
||||
|
||||
return kind switch
|
||||
{
|
||||
RegistryValueKind.Binary => snapshot with { BinaryValue = value as byte[] },
|
||||
RegistryValueKind.MultiString => snapshot with { TextValues = value as string[] },
|
||||
RegistryValueKind.DWord => snapshot with { NumericValue = Convert.ToInt64(value) },
|
||||
RegistryValueKind.QWord => snapshot with { NumericValue = Convert.ToInt64(value) },
|
||||
_ => snapshot with { TextValue = value?.ToString() }
|
||||
};
|
||||
}
|
||||
|
||||
public bool KeyExists(string hive, string keyPath)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: false);
|
||||
using var key = baseKey.OpenSubKey(keyPath, writable: false);
|
||||
return key is not null;
|
||||
}
|
||||
|
||||
public object? Read(string hive, string keyPath, string valueName)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: false);
|
||||
using var key = baseKey.OpenSubKey(keyPath, writable: false);
|
||||
return key?.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
|
||||
}
|
||||
|
||||
public void Set(string hive, string keyPath, string valueName, object value, RegistryValueKind kind)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: true);
|
||||
using var key = baseKey.CreateSubKey(keyPath, writable: true)
|
||||
?? throw new InvalidOperationException($"无法创建注册表项 {hive}\\{keyPath}");
|
||||
key.SetValue(valueName, value, kind);
|
||||
}
|
||||
|
||||
public void DeleteValue(string hive, string keyPath, string valueName)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: true);
|
||||
using var key = baseKey.OpenSubKey(keyPath, writable: true);
|
||||
key?.DeleteValue(valueName, throwOnMissingValue: false);
|
||||
}
|
||||
|
||||
public void DeleteKeyTree(string hive, string keyPath)
|
||||
{
|
||||
using var baseKey = OpenBaseKey(hive, writable: true);
|
||||
baseKey.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
|
||||
public void Restore(RegistryValueSnapshot snapshot)
|
||||
{
|
||||
if (!snapshot.Exists)
|
||||
{
|
||||
DeleteValue(snapshot.Hive, snapshot.KeyPath, snapshot.ValueName);
|
||||
return;
|
||||
}
|
||||
|
||||
var kind = (RegistryValueKind)snapshot.Kind;
|
||||
object value = kind switch
|
||||
{
|
||||
RegistryValueKind.Binary => snapshot.BinaryValue ?? [],
|
||||
RegistryValueKind.MultiString => snapshot.TextValues ?? [],
|
||||
RegistryValueKind.DWord => checked((int)(snapshot.NumericValue ?? 0)),
|
||||
RegistryValueKind.QWord => snapshot.NumericValue ?? 0,
|
||||
_ => snapshot.TextValue ?? string.Empty
|
||||
};
|
||||
Set(snapshot.Hive, snapshot.KeyPath, snapshot.ValueName, value, kind);
|
||||
}
|
||||
|
||||
private static RegistryKey OpenBaseKey(string hive, bool writable)
|
||||
{
|
||||
var registryHive = hive.ToUpperInvariant() switch
|
||||
{
|
||||
"HKCU" or "HKEY_CURRENT_USER" => RegistryHive.CurrentUser,
|
||||
"HKLM" or "HKEY_LOCAL_MACHINE" => RegistryHive.LocalMachine,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(hive), hive, "仅允许 HKCU 和 HKLM")
|
||||
};
|
||||
|
||||
_ = writable;
|
||||
return RegistryKey.OpenBaseKey(registryHive, RegistryView.Registry64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class SnapshotStore
|
||||
{
|
||||
public string GetPath(string feature) => Path.Combine(ToolboxPaths.Snapshots, $"{Sanitize(feature)}.baseline.json");
|
||||
|
||||
public async Task<FeatureSnapshot> GetOrCreateAsync(
|
||||
string feature,
|
||||
Func<FeatureSnapshot> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await LoadAsync(feature, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var snapshot = factory();
|
||||
await JsonFileStore.SaveAsync(GetPath(feature), snapshot, cancellationToken);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async Task<FeatureSnapshot> GetOrCreateAsync(
|
||||
string feature,
|
||||
Func<Task<FeatureSnapshot>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await LoadAsync(feature, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var snapshot = await factory();
|
||||
await JsonFileStore.SaveAsync(GetPath(feature), snapshot, cancellationToken);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public Task<FeatureSnapshot?> LoadAsync(string feature, CancellationToken cancellationToken = default) =>
|
||||
JsonFileStore.LoadAsync<FeatureSnapshot>(GetPath(feature), cancellationToken);
|
||||
|
||||
public void Delete(string feature)
|
||||
{
|
||||
var path = GetPath(feature);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value) => string.Concat(value.Select(c =>
|
||||
char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '_'));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class SystemStatusService(RegistryStore registry, ProcessRunner processes)
|
||||
{
|
||||
private const string CurrentVersionKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion";
|
||||
private const string ClassicKey = @"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32";
|
||||
private const string ShellIconsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Shell Icons";
|
||||
private const string AuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU";
|
||||
private const string UxSettingsKey = @"SOFTWARE\Microsoft\WindowsUpdate\UX\Settings";
|
||||
private const string AutoLogonKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\PasswordLess\Device";
|
||||
private const string SppKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\SoftwareProtectionPlatform";
|
||||
|
||||
public async Task<SystemStatus> DetectAsync(
|
||||
bool includeAdvanced = true,
|
||||
SystemStatus? previous = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var product = registry.Read("HKLM", CurrentVersionKey, "ProductName")?.ToString() ?? "Windows";
|
||||
var edition = registry.Read("HKLM", CurrentVersionKey, "EditionID")?.ToString() ?? "Unknown";
|
||||
var displayVersion = registry.Read("HKLM", CurrentVersionKey, "DisplayVersion")?.ToString()
|
||||
?? registry.Read("HKLM", CurrentVersionKey, "ReleaseId")?.ToString()
|
||||
?? "Unknown";
|
||||
var build = registry.Read("HKLM", CurrentVersionKey, "CurrentBuildNumber")?.ToString() ?? Environment.OSVersion.Version.Build.ToString();
|
||||
var isWindows11 = int.TryParse(build, out var buildNumber) && buildNumber >= 22000;
|
||||
if (isWindows11 && product.Contains("Windows 10", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
product = product.Replace("Windows 10", "Windows 11", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var noAutoUpdate = Convert.ToInt32(registry.Read("HKLM", AuPolicyKey, "NoAutoUpdate") ?? 0) == 1;
|
||||
var pauseText = registry.Read("HKLM", UxSettingsKey, "PauseUpdatesExpiryTime")?.ToString();
|
||||
DateTimeOffset? pauseUntil = DateTimeOffset.TryParse(pauseText, out var parsedPause) ? parsedPause : null;
|
||||
var updateMode = noAutoUpdate
|
||||
? "强力关闭"
|
||||
: pauseUntil > DateTimeOffset.UtcNow
|
||||
? $"暂停至 {pauseUntil.Value.ToLocalTime():yyyy-MM-dd}"
|
||||
: "正常";
|
||||
|
||||
var defenderEnabled = previous?.DefenderRealtimeProtectionEnabled;
|
||||
var tamperEnabled = previous?.TamperProtectionEnabled;
|
||||
var activationStatus = previous?.ActivationStatus ?? "未知";
|
||||
var partialProductKey = previous?.PartialProductKey;
|
||||
if (includeAdvanced)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var advancedTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
advancedTimeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
const string advancedScript =
|
||||
"$r=[ordered]@{Realtime=$null;Tamper=$null;LicenseStatus=$null;PartialProductKey=$null};" +
|
||||
"try{$s=Get-MpComputerStatus -ErrorAction Stop;$r.Realtime=$s.RealTimeProtectionEnabled;$r.Tamper=$s.IsTamperProtected}catch{};" +
|
||||
"try{$p=Get-CimInstance SoftwareLicensingProduct -Filter \"Name like 'Windows%'\" -ErrorAction Stop|" +
|
||||
"Where-Object {$_.PartialProductKey}|Select-Object -First 1;" +
|
||||
"if($null-ne$p){$r.LicenseStatus=[int]$p.LicenseStatus;$r.PartialProductKey=$p.PartialProductKey}}catch{};" +
|
||||
"[pscustomobject]$r|ConvertTo-Json -Compress";
|
||||
var advanced = await processes.RunPowerShellAsync(advancedScript, advancedTimeout.Token);
|
||||
if (advanced.Success && !string.IsNullOrWhiteSpace(advanced.StandardOutput))
|
||||
{
|
||||
using var document = JsonDocument.Parse(advanced.StandardOutput);
|
||||
var root = document.RootElement;
|
||||
if (root.TryGetProperty("Realtime", out var realtime) && realtime.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
defenderEnabled = realtime.GetBoolean();
|
||||
}
|
||||
if (root.TryGetProperty("Tamper", out var tamper) && tamper.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
tamperEnabled = tamper.GetBoolean();
|
||||
}
|
||||
if (root.TryGetProperty("LicenseStatus", out var license) && license.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
activationStatus = license.GetInt32() switch
|
||||
{
|
||||
1 => "已激活",
|
||||
2 => "初始宽限期",
|
||||
3 => "额外宽限期",
|
||||
4 => "非正版宽限期",
|
||||
5 => "通知模式",
|
||||
6 => "延长宽限期",
|
||||
_ => "未激活"
|
||||
};
|
||||
}
|
||||
partialProductKey = root.TryGetProperty("PartialProductKey", out var key) && key.ValueKind == JsonValueKind.String
|
||||
? key.GetString()
|
||||
: partialProductKey;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Advanced status is optional; never hold up the toolbox for more than five seconds.
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Preserve basic status if optional Defender or licensing providers are unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return new SystemStatus
|
||||
{
|
||||
ProductName = product,
|
||||
EditionId = edition,
|
||||
DisplayVersion = displayVersion,
|
||||
Build = build,
|
||||
IsWindows11 = isWindows11,
|
||||
IsAdministrator = IsAdministrator(),
|
||||
ClassicContextMenuEnabled = registry.KeyExists("HKCU", ClassicKey),
|
||||
ShortcutArrowHidden = registry.Read("HKLM", ShellIconsKey, "29") is not null,
|
||||
UpdateMode = updateMode,
|
||||
UpdatePauseUntil = pauseUntil,
|
||||
DefenderRealtimeProtectionEnabled = defenderEnabled,
|
||||
TamperProtectionEnabled = tamperEnabled,
|
||||
AutoLogonOptionPrepared = Convert.ToInt32(registry.Read("HKLM", AutoLogonKey, "DevicePasswordLessBuildVersion") ?? 2) == 0,
|
||||
ActivationStatus = activationStatus,
|
||||
PartialProductKey = partialProductKey,
|
||||
KmsHost = registry.Read("HKLM", SppKey, "KeyManagementServiceName")?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsAdministrator()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class TaskSchedulerService
|
||||
{
|
||||
private const int TaskCreateOrUpdate = 6;
|
||||
private const int TaskLogonInteractiveToken = 3;
|
||||
private const int TaskActionExec = 0;
|
||||
private const int TaskTriggerDaily = 2;
|
||||
private const int TaskTriggerMonthly = 4;
|
||||
|
||||
public bool? TryGetEnabled(string taskPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (folderPath, name) = SplitPath(taskPath);
|
||||
dynamic service = Connect();
|
||||
dynamic folder = service.GetFolder(folderPath);
|
||||
dynamic task = folder.GetTask(name);
|
||||
return (bool)task.Enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySetEnabled(string taskPath, bool enabled, out string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (folderPath, name) = SplitPath(taskPath);
|
||||
dynamic service = Connect();
|
||||
dynamic folder = service.GetFolder(folderPath);
|
||||
dynamic task = folder.GetTask(name);
|
||||
task.Enabled = enabled;
|
||||
message = enabled ? "已启用" : "已禁用";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterDailyTask(string name, string executable, string arguments, TimeOnly runAt, bool highestPrivileges)
|
||||
{
|
||||
dynamic service = Connect();
|
||||
dynamic folder = GetOrCreateToolboxFolder(service);
|
||||
dynamic definition = service.NewTask(0);
|
||||
ConfigureDefinition(definition, executable, arguments, highestPrivileges);
|
||||
dynamic trigger = definition.Triggers.Create(TaskTriggerDaily);
|
||||
var start = DateTime.Today.Add(runAt.ToTimeSpan());
|
||||
if (start <= DateTime.Now)
|
||||
{
|
||||
start = start.AddDays(1);
|
||||
}
|
||||
trigger.StartBoundary = start.ToString("s");
|
||||
trigger.DaysInterval = 1;
|
||||
Register(folder, name, definition);
|
||||
}
|
||||
|
||||
public void RegisterMonthlyTask(string name, string executable, string arguments, int day, TimeOnly runAt)
|
||||
{
|
||||
if (day is < 1 or > 28)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(day), "每月执行日期必须在 1 到 28 之间");
|
||||
}
|
||||
|
||||
dynamic service = Connect();
|
||||
dynamic folder = GetOrCreateToolboxFolder(service);
|
||||
dynamic definition = service.NewTask(0);
|
||||
ConfigureDefinition(definition, executable, arguments, highestPrivileges: false);
|
||||
dynamic trigger = definition.Triggers.Create(TaskTriggerMonthly);
|
||||
var now = DateTime.Now;
|
||||
var start = new DateTime(now.Year, now.Month, day).Add(runAt.ToTimeSpan());
|
||||
if (start <= now)
|
||||
{
|
||||
start = start.AddMonths(1);
|
||||
}
|
||||
trigger.StartBoundary = start.ToString("s");
|
||||
trigger.DaysOfMonth = 1 << (day - 1);
|
||||
trigger.MonthsOfYear = 0xFFF;
|
||||
Register(folder, name, definition);
|
||||
}
|
||||
|
||||
public void DeleteToolboxTask(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
dynamic service = Connect();
|
||||
dynamic folder = GetOrCreateToolboxFolder(service);
|
||||
folder.DeleteTask(name, 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Deleting an absent task is idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureDefinition(dynamic definition, string executable, string arguments, bool highestPrivileges)
|
||||
{
|
||||
definition.RegistrationInfo.Description = "Windows 工具箱自动任务";
|
||||
definition.Principal.UserId = $"{Environment.UserDomainName}\\{Environment.UserName}";
|
||||
definition.Principal.LogonType = TaskLogonInteractiveToken;
|
||||
definition.Principal.RunLevel = highestPrivileges ? 1 : 0;
|
||||
definition.Settings.Enabled = true;
|
||||
definition.Settings.StartWhenAvailable = true;
|
||||
definition.Settings.DisallowStartIfOnBatteries = false;
|
||||
definition.Settings.StopIfGoingOnBatteries = false;
|
||||
definition.Settings.MultipleInstances = 2;
|
||||
dynamic action = definition.Actions.Create(TaskActionExec);
|
||||
action.Path = executable;
|
||||
action.Arguments = arguments;
|
||||
}
|
||||
|
||||
private static void Register(dynamic folder, string name, dynamic definition)
|
||||
{
|
||||
folder.RegisterTaskDefinition(
|
||||
name,
|
||||
definition,
|
||||
TaskCreateOrUpdate,
|
||||
$"{Environment.UserDomainName}\\{Environment.UserName}",
|
||||
null,
|
||||
TaskLogonInteractiveToken,
|
||||
null);
|
||||
}
|
||||
|
||||
private static dynamic GetOrCreateToolboxFolder(dynamic service)
|
||||
{
|
||||
dynamic root = service.GetFolder("\\");
|
||||
try
|
||||
{
|
||||
return root.GetFolder("WindowsToolbox");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return root.CreateFolder("WindowsToolbox");
|
||||
}
|
||||
}
|
||||
|
||||
private static dynamic Connect()
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("Schedule.Service")
|
||||
?? throw new PlatformNotSupportedException("当前系统没有 Task Scheduler 2.0");
|
||||
dynamic service = Activator.CreateInstance(type)
|
||||
?? throw new COMException("无法创建任务计划程序连接");
|
||||
service.Connect();
|
||||
return service;
|
||||
}
|
||||
|
||||
private static (string Folder, string Name) SplitPath(string path)
|
||||
{
|
||||
var normalized = path.StartsWith('\\') ? path : "\\" + path;
|
||||
var index = normalized.LastIndexOf('\\');
|
||||
return (index <= 0 ? "\\" : normalized[..index], normalized[(index + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class ToolboxCommandDispatcher
|
||||
{
|
||||
private readonly AppearanceService _appearance;
|
||||
private readonly WindowsUpdateService _updates;
|
||||
private readonly WindowsSecurityService _security;
|
||||
private readonly AutoLogonService _autoLogon;
|
||||
private readonly DesktopArchiveService _desktop;
|
||||
private readonly ActivationService _activation;
|
||||
private readonly OperationLogStore _logs;
|
||||
|
||||
public ToolboxCommandDispatcher()
|
||||
{
|
||||
var registry = new RegistryStore();
|
||||
var snapshots = new SnapshotStore();
|
||||
var processes = new ProcessRunner();
|
||||
var tasks = new TaskSchedulerService();
|
||||
_appearance = new AppearanceService(registry, snapshots);
|
||||
_updates = new WindowsUpdateService(registry, snapshots, processes, tasks);
|
||||
_security = new WindowsSecurityService(registry, snapshots, processes);
|
||||
_autoLogon = new AutoLogonService(registry, snapshots);
|
||||
_desktop = new DesktopArchiveService(tasks);
|
||||
_activation = new ActivationService(registry, snapshots, processes);
|
||||
_logs = new OperationLogStore();
|
||||
}
|
||||
|
||||
public static bool RequiresElevation(ToolboxCommandKind kind) => kind is not
|
||||
(ToolboxCommandKind.ArchiveDesktop or
|
||||
ToolboxCommandKind.UndoDesktopArchive or
|
||||
ToolboxCommandKind.ConfigureDesktopSchedule or
|
||||
ToolboxCommandKind.DetectStatus);
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var operationLock = new Semaphore(1, 1, @"Local\WindowsToolbox.OperationLock", out _);
|
||||
var acquired = await Task.Run(() => operationLock.WaitOne(TimeSpan.FromMinutes(2)), cancellationToken);
|
||||
if (!acquired)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = false,
|
||||
Summary = "另一个 Windows 工具箱操作仍在执行",
|
||||
Steps = [new CommandStepResult("并发检查", false, "等待两分钟后仍未取得操作锁")]
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
CommandResult result;
|
||||
if (RequiresElevation(command.Kind) && !SystemStatusService.IsAdministrator())
|
||||
{
|
||||
result = new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = false,
|
||||
Summary = "该操作需要管理员权限",
|
||||
Steps = [new CommandStepResult("权限检查", false, "请通过主程序的管理员授权提示执行")]
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result = command.Kind switch
|
||||
{
|
||||
ToolboxCommandKind.EnableClassicContextMenu => await _appearance.EnableClassicContextMenuAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreContextMenu => await _appearance.RestoreContextMenuAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.HideShortcutArrow => await _appearance.HideShortcutArrowAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreShortcutArrow => await _appearance.RestoreShortcutArrowAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.PauseWindowsUpdate => await _updates.PauseAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.DisableWindowsUpdate => await _updates.DisableAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreWindowsUpdate => await _updates.RestoreAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.ConfigureUpdateRenewal => await _updates.ConfigureRenewalAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.DisableDefenderProtection => await _security.DisableAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreDefenderProtection => await _security.RestoreAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.PrepareAutoLogon => await _autoLogon.PrepareAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreAutoLogonPreparation => await _autoLogon.RestoreAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.ArchiveDesktop => await _desktop.ArchiveAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.UndoDesktopArchive => await _desktop.UndoLatestAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.ConfigureDesktopSchedule => await _desktop.ConfigureScheduleAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.ActivateWindowsKms => await _activation.ActivateAsync(command, cancellationToken),
|
||||
ToolboxCommandKind.RestoreKmsConfiguration => await _activation.RestoreAsync(command, cancellationToken),
|
||||
_ => new CommandResult
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = false,
|
||||
Summary = "不支持的操作",
|
||||
Steps = [new CommandStepResult("命令分派", false, command.Kind.ToString())]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _logs.AddAsync(new OperationLogEntry
|
||||
{
|
||||
Id = result.CommandId,
|
||||
Feature = result.Kind.ToString(),
|
||||
Action = result.Kind.ToString(),
|
||||
Success = result.Success,
|
||||
Summary = result.Summary,
|
||||
Steps = result.Steps
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
result.Steps.Add(new CommandStepResult("操作历史", false, "功能已执行,但日志写入失败:" + ex.Message));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.ResultPath))
|
||||
{
|
||||
await JsonFileStore.SaveAsync(command.ResultPath, result, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class WindowsSecurityService(
|
||||
RegistryStore registry,
|
||||
SnapshotStore snapshots,
|
||||
ProcessRunner processes)
|
||||
{
|
||||
private const string Feature = "windows-security";
|
||||
private const string NotificationKey = @"SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Notifications";
|
||||
|
||||
public async Task<CommandResult> DisableAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
await snapshots.GetOrCreateAsync(Feature, () => CaptureBaselineAsync(cancellationToken), cancellationToken);
|
||||
var disableFirewall = command.Arguments.TryGetValue("firewall", out var raw) && bool.TryParse(raw, out var parsed) && parsed;
|
||||
|
||||
var tamper = await processes.RunPowerShellAsync(
|
||||
"$s=Get-MpComputerStatus -ErrorAction Stop; [pscustomobject]@{IsTamperProtected=$s.IsTamperProtected}|ConvertTo-Json -Compress",
|
||||
cancellationToken);
|
||||
var tamperEnabled = ParseBoolean(tamper.StandardOutput, "IsTamperProtected");
|
||||
if (tamperEnabled == true)
|
||||
{
|
||||
steps.Add(new CommandStepResult("防篡改保护", false,
|
||||
"防篡改保护仍然开启。请先在“Windows 安全中心 → 病毒和威胁防护 → 管理设置”中手动关闭。"));
|
||||
return Build(command, steps, "Windows 阻止了 Defender 设置修改");
|
||||
}
|
||||
|
||||
var defenderScript = "$ErrorActionPreference='Stop'; " +
|
||||
"Set-MpPreference -DisableRealtimeMonitoring $true -DisableBehaviorMonitoring $true " +
|
||||
"-DisableBlockAtFirstSeen $true -DisableIOAVProtection $true -DisableScriptScanning $true " +
|
||||
"-MAPSReporting 0 -SubmitSamplesConsent 2; " +
|
||||
"$p=Get-MpPreference; [pscustomobject]@{Disabled=$p.DisableRealtimeMonitoring}|ConvertTo-Json -Compress";
|
||||
var defender = await processes.RunPowerShellAsync(defenderScript, cancellationToken);
|
||||
var defenderDisabled = defender.Success && ParseBoolean(defender.StandardOutput, "Disabled") == true;
|
||||
steps.Add(new CommandStepResult("Defender 防护", defenderDisabled,
|
||||
defenderDisabled ? "实时、行为、脚本、IOAV 和云端样本防护已关闭" : Friendly(defender, "Defender 修改失败")));
|
||||
|
||||
registry.Set("HKLM", NotificationKey, "DisableNotifications", 1, RegistryValueKind.DWord);
|
||||
registry.Set("HKLM", NotificationKey, "DisableEnhancedNotifications", 1, RegistryValueKind.DWord);
|
||||
steps.Add(new CommandStepResult("安全通知", true, "安全中心通知策略已关闭"));
|
||||
|
||||
if (disableFirewall)
|
||||
{
|
||||
var firewall = await processes.RunPowerShellAsync(
|
||||
"$ErrorActionPreference='Stop'; Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False",
|
||||
cancellationToken);
|
||||
steps.Add(new CommandStepResult("Windows 防火墙", firewall.Success,
|
||||
firewall.Success ? "三个网络配置文件的防火墙均已关闭" : Friendly(firewall, "防火墙修改失败")));
|
||||
}
|
||||
|
||||
return Build(command, steps, defenderDisabled ? "Windows 防护组件已按所选范围关闭" : "部分安全设置未能关闭");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("关闭安全防护", false, ex.Message));
|
||||
return Build(command, steps, "关闭 Windows 防护失败");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(Feature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
registry.DeleteValue("HKLM", NotificationKey, "DisableNotifications");
|
||||
registry.DeleteValue("HKLM", NotificationKey, "DisableEnhancedNotifications");
|
||||
steps.Add(new CommandStepResult("安全通知", true, "已清除本工具使用的通知策略"));
|
||||
return Build(command, steps, "没有找到 Defender 基线快照;只清除了工具策略");
|
||||
}
|
||||
|
||||
if (snapshot.Metadata.TryGetValue("defender", out var defenderJson) && !string.IsNullOrWhiteSpace(defenderJson))
|
||||
{
|
||||
var script = BuildDefenderRestoreScript(defenderJson);
|
||||
var defender = await processes.RunPowerShellAsync(script, cancellationToken);
|
||||
steps.Add(new CommandStepResult("Defender 防护", defender.Success,
|
||||
defender.Success ? "Defender 偏好已恢复" : Friendly(defender, "Defender 恢复失败,可能仍受防篡改保护阻止")));
|
||||
}
|
||||
|
||||
if (snapshot.Metadata.TryGetValue("firewall", out var firewallJson) && !string.IsNullOrWhiteSpace(firewallJson))
|
||||
{
|
||||
var script = BuildFirewallRestoreScript(firewallJson);
|
||||
var firewall = await processes.RunPowerShellAsync(script, cancellationToken);
|
||||
steps.Add(new CommandStepResult("Windows 防火墙", firewall.Success,
|
||||
firewall.Success ? "防火墙配置文件状态已恢复" : Friendly(firewall, "防火墙恢复失败")));
|
||||
}
|
||||
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
steps.Add(new CommandStepResult("安全通知", true, "安全中心通知策略已恢复"));
|
||||
|
||||
if (steps.All(x => x.Success))
|
||||
{
|
||||
snapshots.Delete(Feature);
|
||||
}
|
||||
|
||||
return Build(command, steps, steps.All(x => x.Success) ? "Windows 安全设置已恢复" : "部分安全设置恢复失败");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("恢复安全设置", false, ex.Message));
|
||||
return Build(command, steps, "恢复 Windows 安全设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FeatureSnapshot> CaptureBaselineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var defender = await processes.RunPowerShellAsync(
|
||||
"$p=Get-MpPreference -ErrorAction Stop; [pscustomobject]@{" +
|
||||
"DisableRealtimeMonitoring=$p.DisableRealtimeMonitoring;" +
|
||||
"DisableBehaviorMonitoring=$p.DisableBehaviorMonitoring;" +
|
||||
"DisableBlockAtFirstSeen=$p.DisableBlockAtFirstSeen;" +
|
||||
"DisableIOAVProtection=$p.DisableIOAVProtection;" +
|
||||
"DisableScriptScanning=$p.DisableScriptScanning;" +
|
||||
"MAPSReporting=[int]$p.MAPSReporting;" +
|
||||
"SubmitSamplesConsent=[int]$p.SubmitSamplesConsent}|ConvertTo-Json -Compress", cancellationToken);
|
||||
var firewall = await processes.RunPowerShellAsync(
|
||||
"Get-NetFirewallProfile | Select-Object Name,Enabled | ConvertTo-Json -Compress", cancellationToken);
|
||||
|
||||
return new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = Feature,
|
||||
RegistryValues =
|
||||
[
|
||||
registry.Capture("HKLM", NotificationKey, "DisableNotifications"),
|
||||
registry.Capture("HKLM", NotificationKey, "DisableEnhancedNotifications")
|
||||
],
|
||||
Metadata =
|
||||
{
|
||||
["defender"] = defender.Success ? defender.StandardOutput.Trim() : null,
|
||||
["firewall"] = firewall.Success ? firewall.StandardOutput.Trim() : null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildDefenderRestoreScript(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
static string PsBool(bool value) => value ? "$true" : "$false";
|
||||
bool Bool(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.GetBoolean();
|
||||
int Int(JsonElement element, string name) => element.TryGetProperty(name, out var value) ? value.GetInt32() : 0;
|
||||
return "$ErrorActionPreference='Stop'; Set-MpPreference " +
|
||||
$"-DisableRealtimeMonitoring {PsBool(Bool(root, "DisableRealtimeMonitoring"))} " +
|
||||
$"-DisableBehaviorMonitoring {PsBool(Bool(root, "DisableBehaviorMonitoring"))} " +
|
||||
$"-DisableBlockAtFirstSeen {PsBool(Bool(root, "DisableBlockAtFirstSeen"))} " +
|
||||
$"-DisableIOAVProtection {PsBool(Bool(root, "DisableIOAVProtection"))} " +
|
||||
$"-DisableScriptScanning {PsBool(Bool(root, "DisableScriptScanning"))} " +
|
||||
$"-MAPSReporting {Int(root, "MAPSReporting")} " +
|
||||
$"-SubmitSamplesConsent {Int(root, "SubmitSamplesConsent")}";
|
||||
}
|
||||
|
||||
private static string BuildFirewallRestoreScript(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var items = document.RootElement.ValueKind == JsonValueKind.Array
|
||||
? document.RootElement.EnumerateArray().ToArray()
|
||||
: [document.RootElement];
|
||||
var commands = new List<string> { "$ErrorActionPreference='Stop'" };
|
||||
foreach (var item in items)
|
||||
{
|
||||
var name = item.GetProperty("Name").GetString();
|
||||
if (name is not ("Domain" or "Public" or "Private"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var enabled = item.GetProperty("Enabled").GetBoolean() ? "$true" : "$false";
|
||||
commands.Add($"Set-NetFirewallProfile -Profile {name} -Enabled {enabled}");
|
||||
}
|
||||
return string.Join("; ", commands);
|
||||
}
|
||||
|
||||
private static bool? ParseBoolean(string json, string property)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.TryGetProperty(property, out var value) ? value.GetBoolean() : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult Build(ToolboxCommand command, List<CommandStepResult> steps, string summary) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = steps.Count > 0 && steps.All(x => x.Success),
|
||||
Summary = summary,
|
||||
Steps = steps
|
||||
};
|
||||
|
||||
private static string Friendly(ProcessResult result, string fallback) =>
|
||||
string.IsNullOrWhiteSpace(result.CombinedOutput) ? fallback : result.CombinedOutput.Trim();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WindowsToolbox.Core\WindowsToolbox.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,283 @@
|
||||
using Microsoft.Win32;
|
||||
using WindowsToolbox.Core;
|
||||
|
||||
namespace WindowsToolbox.Infrastructure;
|
||||
|
||||
public sealed class WindowsUpdateService(
|
||||
RegistryStore registry,
|
||||
SnapshotStore snapshots,
|
||||
ProcessRunner processes,
|
||||
TaskSchedulerService tasks)
|
||||
{
|
||||
private const string Feature = "windows-update";
|
||||
private const string AuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU";
|
||||
private const string UxSettingsKey = @"SOFTWARE\Microsoft\WindowsUpdate\UX\Settings";
|
||||
private const string UpdateGuardTask = "UpdateGuard";
|
||||
private const string PauseRenewTask = "UpdatePauseRenew";
|
||||
|
||||
private static readonly string[] PauseValueNames =
|
||||
[
|
||||
"PauseUpdatesStartTime", "PauseUpdatesExpiryTime",
|
||||
"PauseFeatureUpdatesStartTime", "PauseFeatureUpdatesEndTime",
|
||||
"PauseQualityUpdatesStartTime", "PauseQualityUpdatesEndTime"
|
||||
];
|
||||
|
||||
private static readonly string[] Services = ["wuauserv", "UsoSvc", "WaaSMedicSvc"];
|
||||
|
||||
private static readonly string[] UpdateTasks =
|
||||
[
|
||||
@"\Microsoft\Windows\UpdateOrchestrator\Schedule Scan",
|
||||
@"\Microsoft\Windows\UpdateOrchestrator\Schedule Maintenance Work",
|
||||
@"\Microsoft\Windows\UpdateOrchestrator\USO_UxBroker",
|
||||
@"\Microsoft\Windows\WindowsUpdate\Scheduled Start",
|
||||
@"\Microsoft\Windows\WindowsUpdate\sih",
|
||||
@"\Microsoft\Windows\WindowsUpdate\sihboot"
|
||||
];
|
||||
|
||||
public async Task<CommandResult> PauseAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var days = int.TryParse(command.Arguments.GetValueOrDefault("days"), out var parsed) ? parsed : 35;
|
||||
days = Math.Clamp(days, 1, 35);
|
||||
var snapshot = await GetBaselineAsync(cancellationToken);
|
||||
await RestoreControlledStateAsync(snapshot, restoreRunningServices: true, cancellationToken);
|
||||
|
||||
var start = DateTimeOffset.UtcNow;
|
||||
var end = start.AddDays(days);
|
||||
registry.Set("HKLM", AuPolicyKey, "NoAutoUpdate", 0, RegistryValueKind.DWord);
|
||||
registry.Set("HKLM", AuPolicyKey, "AUOptions", 2, RegistryValueKind.DWord);
|
||||
SetPauseValues(start, end);
|
||||
tasks.DeleteToolboxTask(UpdateGuardTask);
|
||||
|
||||
var actual = registry.Read("HKLM", UxSettingsKey, "PauseUpdatesExpiryTime")?.ToString();
|
||||
var verified = !string.IsNullOrWhiteSpace(actual);
|
||||
steps.Add(new CommandStepResult("暂停更新", verified,
|
||||
verified ? $"已请求暂停至 {end.ToLocalTime():yyyy-MM-dd HH:mm}" : "暂停时间写入后验证失败"));
|
||||
return Build(command, steps, verified ? "Windows 更新已暂停" : "Windows 未接受暂停设置");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("暂停更新", false, ex.Message));
|
||||
return Build(command, steps, "暂停更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> DisableAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
await GetBaselineAsync(cancellationToken);
|
||||
registry.Set("HKLM", AuPolicyKey, "NoAutoUpdate", 1, RegistryValueKind.DWord);
|
||||
registry.Set("HKLM", AuPolicyKey, "AUOptions", 2, RegistryValueKind.DWord);
|
||||
ClearPauseValues();
|
||||
steps.Add(new CommandStepResult("更新策略", true, "已设置禁止自动更新策略"));
|
||||
|
||||
foreach (var service in Services)
|
||||
{
|
||||
var stop = await processes.RunAsync("sc.exe", ["stop", service], cancellationToken);
|
||||
var disable = await processes.RunAsync("sc.exe", ["config", service, "start=", "disabled"], cancellationToken);
|
||||
var success = disable.Success;
|
||||
var message = success
|
||||
? "已停止并禁用"
|
||||
: FriendlyOutput(disable, "系统拒绝修改;通常是受保护服务");
|
||||
steps.Add(new CommandStepResult($"服务 {service}", success, message));
|
||||
_ = stop;
|
||||
}
|
||||
|
||||
foreach (var taskPath in UpdateTasks)
|
||||
{
|
||||
if (tasks.TrySetEnabled(taskPath, false, out var message))
|
||||
{
|
||||
steps.Add(new CommandStepResult($"任务 {ShortTaskName(taskPath)}", true, message));
|
||||
}
|
||||
else
|
||||
{
|
||||
steps.Add(new CommandStepResult($"任务 {ShortTaskName(taskPath)}", false, message));
|
||||
}
|
||||
}
|
||||
|
||||
tasks.DeleteToolboxTask(PauseRenewTask);
|
||||
if (!command.Arguments.ContainsKey("guard"))
|
||||
{
|
||||
var host = CompanionLocator.Find("WindowsToolbox.ElevatedHost.exe");
|
||||
tasks.RegisterDailyTask(UpdateGuardTask, host, "--scheduled-update-disable", new TimeOnly(9, 15), highestPrivileges: true);
|
||||
steps.Add(new CommandStepResult("关闭守护任务", true, "每天检查一次更新关闭状态"));
|
||||
}
|
||||
|
||||
var policyDisabled = Convert.ToInt32(registry.Read("HKLM", AuPolicyKey, "NoAutoUpdate") ?? 0) == 1;
|
||||
var updateServiceDisabled = Convert.ToInt32(registry.Read(
|
||||
"HKLM", @"SYSTEM\CurrentControlSet\Services\wuauserv", "Start") ?? 0) == 4;
|
||||
var disabled = policyDisabled && updateServiceDisabled;
|
||||
steps.Add(new CommandStepResult("结果验证", disabled,
|
||||
disabled ? "关闭策略和 Windows Update 服务均已验证" : "策略或 Windows Update 服务没有进入关闭状态"));
|
||||
return Build(command, steps, disabled ? "Windows 自动更新已强力关闭" : "Windows 自动更新未完全关闭,请查看失败步骤");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("关闭更新", false, ex.Message));
|
||||
return Build(command, steps, "关闭 Windows 更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CommandResult> RestoreAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var snapshot = await snapshots.LoadAsync(Feature, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
registry.DeleteValue("HKLM", AuPolicyKey, "NoAutoUpdate");
|
||||
registry.DeleteValue("HKLM", AuPolicyKey, "AUOptions");
|
||||
ClearPauseValues();
|
||||
steps.Add(new CommandStepResult("恢复策略", true, "没有基线快照,已清除本工具使用的策略"));
|
||||
}
|
||||
else
|
||||
{
|
||||
await RestoreControlledStateAsync(snapshot, restoreRunningServices: true, cancellationToken);
|
||||
steps.Add(new CommandStepResult("恢复策略与服务", true, "已恢复执行工具前的状态"));
|
||||
}
|
||||
|
||||
tasks.DeleteToolboxTask(UpdateGuardTask);
|
||||
tasks.DeleteToolboxTask(PauseRenewTask);
|
||||
snapshots.Delete(Feature);
|
||||
return Build(command, steps, "Windows 更新设置已恢复");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("恢复更新", false, ex.Message));
|
||||
return Build(command, steps, "恢复 Windows 更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<CommandResult> ConfigureRenewalAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steps = new List<CommandStepResult>();
|
||||
try
|
||||
{
|
||||
var enabled = command.Arguments.TryGetValue("enabled", out var value) && bool.Parse(value);
|
||||
var days = int.TryParse(command.Arguments.GetValueOrDefault("days"), out var parsed) ? Math.Clamp(parsed, 1, 35) : 35;
|
||||
if (enabled)
|
||||
{
|
||||
var host = CompanionLocator.Find("WindowsToolbox.ElevatedHost.exe");
|
||||
tasks.RegisterDailyTask(PauseRenewTask, host, $"--scheduled-update-renew {days}", new TimeOnly(9, 0), highestPrivileges: true);
|
||||
steps.Add(new CommandStepResult("自动续期", true, $"每天检查并重新请求暂停 {days} 天"));
|
||||
}
|
||||
else
|
||||
{
|
||||
tasks.DeleteToolboxTask(PauseRenewTask);
|
||||
steps.Add(new CommandStepResult("自动续期", true, "已关闭暂停自动续期"));
|
||||
}
|
||||
|
||||
return Task.FromResult(Build(command, steps, enabled ? "更新暂停自动续期已启用" : "更新暂停自动续期已关闭"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
steps.Add(new CommandStepResult("自动续期", false, ex.Message));
|
||||
return Task.FromResult(Build(command, steps, "自动续期配置失败"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FeatureSnapshot> GetBaselineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await snapshots.GetOrCreateAsync(Feature, async () =>
|
||||
{
|
||||
var snapshot = new FeatureSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Feature = Feature
|
||||
};
|
||||
snapshot.RegistryValues.Add(registry.Capture("HKLM", AuPolicyKey, "NoAutoUpdate"));
|
||||
snapshot.RegistryValues.Add(registry.Capture("HKLM", AuPolicyKey, "AUOptions"));
|
||||
foreach (var name in PauseValueNames)
|
||||
{
|
||||
snapshot.RegistryValues.Add(registry.Capture("HKLM", UxSettingsKey, name));
|
||||
}
|
||||
|
||||
foreach (var service in Services)
|
||||
{
|
||||
snapshot.RegistryValues.Add(registry.Capture("HKLM", $@"SYSTEM\CurrentControlSet\Services\{service}", "Start"));
|
||||
var result = await processes.RunAsync("sc.exe", ["query", service], cancellationToken);
|
||||
snapshot.Metadata[$"service-running:{service}"] =
|
||||
System.Text.RegularExpressions.Regex.IsMatch(
|
||||
result.StandardOutput,
|
||||
@"STATE\s*:\s*4\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase).ToString();
|
||||
}
|
||||
|
||||
foreach (var taskPath in UpdateTasks)
|
||||
{
|
||||
var enabled = tasks.TryGetEnabled(taskPath);
|
||||
snapshot.Metadata[$"task:{taskPath}"] = enabled?.ToString();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RestoreControlledStateAsync(FeatureSnapshot snapshot, bool restoreRunningServices, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var value in snapshot.RegistryValues)
|
||||
{
|
||||
registry.Restore(value);
|
||||
}
|
||||
|
||||
foreach (var taskPath in UpdateTasks)
|
||||
{
|
||||
if (snapshot.Metadata.TryGetValue($"task:{taskPath}", out var raw) && bool.TryParse(raw, out var enabled))
|
||||
{
|
||||
tasks.TrySetEnabled(taskPath, enabled, out _);
|
||||
}
|
||||
}
|
||||
|
||||
if (!restoreRunningServices)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var service in Services)
|
||||
{
|
||||
if (snapshot.Metadata.TryGetValue($"service-running:{service}", out var raw) && bool.TryParse(raw, out var wasRunning) && wasRunning)
|
||||
{
|
||||
await processes.RunAsync("sc.exe", ["start", service], cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPauseValues(DateTimeOffset start, DateTimeOffset end)
|
||||
{
|
||||
var startText = start.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var endText = end.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseUpdatesStartTime", startText, RegistryValueKind.String);
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseUpdatesExpiryTime", endText, RegistryValueKind.String);
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseFeatureUpdatesStartTime", startText, RegistryValueKind.String);
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseFeatureUpdatesEndTime", endText, RegistryValueKind.String);
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseQualityUpdatesStartTime", startText, RegistryValueKind.String);
|
||||
registry.Set("HKLM", UxSettingsKey, "PauseQualityUpdatesEndTime", endText, RegistryValueKind.String);
|
||||
}
|
||||
|
||||
private void ClearPauseValues()
|
||||
{
|
||||
foreach (var name in PauseValueNames)
|
||||
{
|
||||
registry.DeleteValue("HKLM", UxSettingsKey, name);
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult Build(ToolboxCommand command, List<CommandStepResult> steps, string summary) => new()
|
||||
{
|
||||
CommandId = command.Id,
|
||||
Kind = command.Kind,
|
||||
Success = steps.Count > 0 && steps.Any(x => x.Success) && steps.Last().Success,
|
||||
Summary = summary,
|
||||
Steps = steps
|
||||
};
|
||||
|
||||
private static string ShortTaskName(string path) => path[(path.LastIndexOf('\\') + 1)..];
|
||||
|
||||
private static string FriendlyOutput(ProcessResult result, string fallback) =>
|
||||
string.IsNullOrWhiteSpace(result.CombinedOutput) ? fallback : result.CombinedOutput.Trim();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using WindowsToolbox.Core;
|
||||
using WindowsToolbox.Infrastructure;
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
Check(ToolboxCommandDispatcher.RequiresElevation(ToolboxCommandKind.DisableWindowsUpdate),
|
||||
"关闭更新必须提权");
|
||||
Check(!ToolboxCommandDispatcher.RequiresElevation(ToolboxCommandKind.ArchiveDesktop),
|
||||
"桌面归档不应要求提权");
|
||||
|
||||
var command = new ToolboxCommand
|
||||
{
|
||||
Id = "verification",
|
||||
Kind = ToolboxCommandKind.PauseWindowsUpdate,
|
||||
Arguments = new Dictionary<string, string> { ["days"] = "35" }
|
||||
};
|
||||
var roundTrip = JsonSerializer.Deserialize<ToolboxCommand>(
|
||||
JsonSerializer.Serialize(command, JsonDefaults.Options), JsonDefaults.Options);
|
||||
Check(roundTrip?.Kind == ToolboxCommandKind.PauseWindowsUpdate, "命令 JSON 往返失败");
|
||||
Check(roundTrip?.Arguments.GetValueOrDefault("days") == "35", "命令参数 JSON 往返失败");
|
||||
|
||||
var activation = new ActivationService(new RegistryStore(), new SnapshotStore(), new ProcessRunner());
|
||||
var catalog = activation.LoadCatalog();
|
||||
Check(catalog.Any(x => x.EditionId == "Professional" && x.ProductKey == "W269N-WFGWX-YVC9B-4J6C9-T83GX"),
|
||||
"KMS 专业版目录缺失");
|
||||
Check(catalog.Any(x => x.EditionId == "Core" && !x.Supported),
|
||||
"家庭版应明确标记为不支持 KMS");
|
||||
Check(catalog.Where(x => x.Supported).All(x => x.ProductKey.Length == 29),
|
||||
"KMS 客户端密钥格式错误");
|
||||
|
||||
var temporaryRoot = Path.Combine(Path.GetTempPath(), "WindowsToolbox-Verification-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var testDesktop = Path.Combine(temporaryRoot, "Desktop");
|
||||
var testDestination = Path.Combine(temporaryRoot, "Archive");
|
||||
Directory.CreateDirectory(testDesktop);
|
||||
File.WriteAllText(Path.Combine(testDesktop, "report.txt"), "first");
|
||||
File.WriteAllText(Path.Combine(testDesktop, "keep.lnk"), "shortcut");
|
||||
var projectFolder = Path.Combine(testDesktop, "Project");
|
||||
Directory.CreateDirectory(projectFolder);
|
||||
File.WriteAllText(Path.Combine(projectFolder, "first.txt"), "folder-first");
|
||||
var systemFolder = Path.Combine(testDesktop, "SystemFolder");
|
||||
Directory.CreateDirectory(systemFolder);
|
||||
File.SetAttributes(systemFolder, File.GetAttributes(systemFolder) | FileAttributes.System);
|
||||
|
||||
var desktopService = new DesktopArchiveService(
|
||||
new TaskSchedulerService(),
|
||||
() => testDesktop,
|
||||
Path.Combine(temporaryRoot, "Data"));
|
||||
var archiveCommand = new ToolboxCommand
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Kind = ToolboxCommandKind.ArchiveDesktop,
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["profile"] = "verification",
|
||||
["destination"] = testDestination
|
||||
}
|
||||
};
|
||||
var firstArchive = await desktopService.ArchiveAsync(archiveCommand);
|
||||
var dateFolder = Path.Combine(testDestination, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
Check(firstArchive.Success, "第一次桌面归档失败");
|
||||
Check(File.Exists(Path.Combine(dateFolder, "report.txt")), "普通文件没有归档");
|
||||
Check(File.Exists(Path.Combine(testDesktop, "keep.lnk")), "快捷方式不应被移动");
|
||||
Check(Directory.Exists(Path.Combine(dateFolder, "Project")), "普通文件夹没有归档");
|
||||
Check(Directory.Exists(systemFolder), "系统文件夹不应被移动");
|
||||
|
||||
await Task.Delay(5);
|
||||
File.WriteAllText(Path.Combine(testDesktop, "report.txt"), "second");
|
||||
Directory.CreateDirectory(projectFolder);
|
||||
File.WriteAllText(Path.Combine(projectFolder, "second.txt"), "folder-second");
|
||||
var secondArchive = await desktopService.ArchiveAsync(archiveCommand with { Id = Guid.NewGuid().ToString("N") });
|
||||
Check(secondArchive.Success, "第二次桌面归档失败");
|
||||
Check(File.Exists(Path.Combine(dateFolder, "report (1).txt")), "同一天同名文件没有自动编号");
|
||||
Check(Directory.Exists(Path.Combine(dateFolder, "Project (1)")), "同一天同名文件夹没有自动编号");
|
||||
|
||||
await Task.Delay(5);
|
||||
Directory.CreateDirectory(projectFolder);
|
||||
File.WriteAllText(Path.Combine(projectFolder, "third.txt"), "folder-third");
|
||||
var thirdArchive = await desktopService.ArchiveAsync(archiveCommand with { Id = Guid.NewGuid().ToString("N") });
|
||||
Check(thirdArchive.Success, "第三次桌面归档失败");
|
||||
Check(Directory.Exists(Path.Combine(dateFolder, "Project (2)")), "多次同名文件夹没有继续自动编号");
|
||||
|
||||
var undo = await desktopService.UndoLatestAsync(new ToolboxCommand
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Kind = ToolboxCommandKind.UndoDesktopArchive
|
||||
});
|
||||
Check(undo.Success, "撤销最近桌面归档失败");
|
||||
Check(File.Exists(Path.Combine(testDesktop, "Project", "third.txt")), "撤销没有恢复最近一次文件夹");
|
||||
Check(File.Exists(Path.Combine(dateFolder, "report.txt")), "撤销最近一次不应影响更早归档");
|
||||
Check(File.Exists(Path.Combine(dateFolder, "report (1).txt")), "撤销文件夹归档不应影响更早归档文件");
|
||||
Check(Directory.Exists(Path.Combine(dateFolder, "Project")), "撤销最近一次不应影响更早归档文件夹");
|
||||
Check(Directory.Exists(Path.Combine(dateFolder, "Project (1)")), "撤销最近一次不应影响第二次归档文件夹");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(temporaryRoot))
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFileSystemEntries(temporaryRoot, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { File.SetAttributes(path, FileAttributes.Normal); } catch { }
|
||||
}
|
||||
Directory.Delete(temporaryRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine(string.Join(Environment.NewLine, failures));
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"验证通过:{catalog.Count} 个 KMS 版本条目;命令模型、权限边界、文件与文件夹归档、系统项排除、同名处理和撤销正常。");
|
||||
return 0;
|
||||
|
||||
void Check(bool condition, string message)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
failures.Add("失败: " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\WindowsToolbox.Core\WindowsToolbox.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\WindowsToolbox.Infrastructure\WindowsToolbox.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user