新增下载目录整理、修复计划任务状态回显并优化启动检测速度

- 下载目录整理:按当天日期在下载目录内建子目录,文件按类型分类(安装包/压缩包/文档/图片/视频/音频/其他),文件夹进日期目录根;跳过下载中临时文件、日期格式旧目录和系统项;支持撤销与每月计划任务
- 修复:重开软件后目录整理页现在回填已保存配置,并以任务计划程序实际状态为准显示开关;撤销按 profile 过滤,桌面与下载互不干扰
- 性能:激活状态查询移出启动流程,仅在 KMS 页/相关操作/手动刷新时按需执行;Defender 状态后台异步刷新;新增状态缓存供下次启动首显
- 去除 D:\desktop 硬编码默认值;Verification 增加下载整理、同日合并、排除规则与撤销过滤测试
This commit is contained in:
leefer
2026-09-19 21:47:47 +08:00
parent 89c48131b2
commit e70cd0f604
12 changed files with 846 additions and 104 deletions
+5 -3
View File
@@ -10,11 +10,12 @@
- Windows 安全:检测防篡改保护、关闭可控制的 Defender 防护、可选关闭防火墙、恢复原始设置。
- 自动登录:恢复 `netplwiz` 中可能隐藏的密码复选框并打开 Windows 自带账户控制;工具不读取密码。
- 桌面归档:使用 Known Folder API 识别真实桌面,按 `yyyy-MM-dd` 归档普通文件和普通文件夹;排除快捷方式、系统项及重解析目录,自动处理同名文件/文件夹,支持撤销和每月计划任务。
- 下载目录整理:在下载目录内按当天日期创建子目录,文件按类型(安装包、压缩包、文档、图片、视频、音频、其他)分类移入,文件夹进入日期目录根;跳过正在下载的临时文件和日期格式旧目录,支持撤销和每月计划任务。
- KMS:内置微软公开的 Windows 10/11 GVLK,使用用户填写的本地 KMS 地址,连接成功后才安装密钥,支持恢复原 KMS 主机。
- 系统状态、逐步执行结果及最近 100 次操作历史。
- 多尺寸应用图标,同时用于窗口标题栏、任务栏和 EXE 文件。
启动时只运行一个有 5 秒硬超时的可选状态查询进程,主窗口不会等待它才显示;普通功能完成后只刷新注册表基础状态,不再重复等待 Defender 与许可证查询。
启动时立即显示注册表状态和上次安全/激活状态缓存,Defender 状态在后台刷新,激活状态只在打开 KMS 页面时按需查询;主窗口不等待任何重量级查询。普通功能完成后只刷新注册表基础状态。
## 运行
@@ -52,10 +53,11 @@ artifacts\WindowsToolbox\
```text
%LOCALAPPDATA%\WindowsToolbox\
├─ ArchiveManifests\ 桌面归档恢复清单
├─ ArchiveManifests\ 桌面归档与下载整理的恢复清单
├─ Cache\ 安全与激活状态缓存
├─ Logs\ 操作历史
├─ Pending\ 临时提权命令及结果
└─ Profiles\ 桌面归档配置
└─ Profiles\ 桌面归档与下载整理配置
%PROGRAMDATA%\WindowsToolbox\
├─ Assets\ 透明快捷方式图标
+21
View File
@@ -32,6 +32,27 @@
普通隐藏文件会归档。虚拟的“此电脑”“回收站”等系统图标不属于文件系统枚举范围,因此不会移动。同名目标采用 `名称 (1).扩展名` 或 `文件夹 (1)` 递增。每次执行都生成恢复清单;撤销只移动仍然存在于归档目录中的项目。
## 下载目录整理
与桌面归档共用恢复清单和任务计划机制,但整理发生在下载目录内部:
- 在下载目录下按当天日期创建子目录(如 `2026-09-19`)。
- 文件按扩展名进入类型子目录:安装包、压缩包、文档、图片、视频、音频、其他。
- 普通文件夹没有类型可言,直接进入日期目录根。
- 同日重复整理时复用日期目录,同名项目自动编号。
始终原地保留:
- 名称形如 `yyyy-MM-dd` 的目录(含当天日期目录本身,避免整理结果被再次移动)。
- `.crdownload`、`.part` 等浏览器下载未完成的临时文件。
- `desktop.ini`、带 System 属性的项目和重解析点。
下载与桌面各自的撤销只读取自己 profile 的最近清单,互不影响。
## 状态检测
注册表状态即时读取。Defender 状态启动后在后台刷新并写入缓存;激活状态只在打开 KMS 页面、执行相关操作或手动刷新时查询。缓存位于 `%LOCALAPPDATA%\WindowsToolbox\Cache\`,仅用于下次启动的首屏显示。
## KMS
密钥目录位于 `data\kms-keys.json`,可以手动维护。激活顺序为:
+36 -4
View File
@@ -130,6 +130,8 @@
</Border>
</UniformGrid>
<TextBlock Text="{Binding AdvancedSourceText}" Style="{StaticResource DescriptionStyle}" Margin="2,0,0,16" />
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="最近一次操作" Style="{StaticResource SectionTitleStyle}" />
@@ -241,11 +243,12 @@
<!-- 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" />
<TextBlock Text="目录整理" Style="{StaticResource PageTitleStyle}" />
<TextBlock Text="桌面归档到外部目录;下载目录就地按当天日期归类。两者都可撤销和按月计划执行。" Style="{StaticResource DescriptionStyle}" Margin="0,0,0,22" />
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="目标位置" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="桌面归档(移动到外部目录)" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="按 yyyy-MM-dd 归档到所选目录;包含普通文件夹,仅排除快捷方式和系统项。" Style="{StaticResource DescriptionStyle}" />
<Grid Margin="0,8,0,4">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBox Text="{Binding DesktopDestination, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource InputStyle}" />
@@ -259,7 +262,7 @@
</Border>
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="每月自动执行" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="桌面每月自动执行" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="使用当前登录用户的任务计划;电脑关机错过时间后会在下次可用时执行。" Style="{StaticResource DescriptionStyle}" />
<WrapPanel Margin="0,10,0,0">
<StackPanel Width="180" Margin="0,0,14,0">
@@ -275,6 +278,35 @@
<Button Content="保存计划任务" Command="{Binding ConfigureDesktopScheduleCommand}" Style="{StaticResource PrimaryButtonStyle}" HorizontalAlignment="Left" />
</StackPanel>
</Border>
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="下载目录整理(就地按日期归类)" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="在下载目录内创建当天日期的子目录,例如 2026-09-19;文件按类型进入安装包、压缩包、文档、图片、视频、音频、其他子目录,文件夹直接进入日期目录。" Style="{StaticResource DescriptionStyle}" />
<TextBlock Text="不会移动日期格式的旧目录、正在下载的临时文件(如 .crdownload)和系统项。" Style="{StaticResource DescriptionStyle}" />
<WrapPanel Margin="0,10,0,0">
<Button Content="立即整理" Command="{Binding TidyDownloadsCommand}" Style="{StaticResource PrimaryButtonStyle}" />
<Button Content="撤销最近一次整理" Command="{Binding UndoDownloadsTidyCommand}" 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 DownloadsMonthlyDay}" Margin="0,6,0,0" />
</StackPanel>
<StackPanel Width="180">
<TextBlock Text="执行时间(HH:mm)" Foreground="{StaticResource MutedTextBrush}" />
<TextBox Text="{Binding DownloadsScheduleTime}" Style="{StaticResource InputStyle}" Margin="0,6,0,0" />
</StackPanel>
</WrapPanel>
<CheckBox Content="启用每月自动整理" IsChecked="{Binding DownloadsScheduleEnabled}" />
<Button Content="保存计划任务" Command="{Binding ConfigureDownloadsScheduleCommand}" Style="{StaticResource PrimaryButtonStyle}" HorizontalAlignment="Left" />
</StackPanel>
</Border>
</StackPanel>
<!-- Activation -->
+172 -13
View File
@@ -29,7 +29,10 @@ public sealed class MainViewModel : ObservableObject
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 readonly DesktopArchiveService _desktopService = new(new TaskSchedulerService());
private readonly DownloadsTidyService _downloadsService = new(new TaskSchedulerService());
private SystemStatus? _status;
private StatusCache? _statusCache;
private ToolboxPage _selectedPage;
private bool _isBusy;
private string _lastMessage = "准备读取系统状态…";
@@ -41,16 +44,19 @@ public sealed class MainViewModel : ObservableObject
private bool _desktopScheduleEnabled;
private int _selectedMonthlyDay = 1;
private string _scheduleTime = "09:00";
private bool _downloadsScheduleEnabled;
private int _downloadsMonthlyDay = 1;
private string _downloadsScheduleTime = "09:00";
private string _advancedSourceText = string.Empty;
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;
_desktopDestination = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"DesktopArchive");
Navigation =
[
@@ -58,7 +64,7 @@ public sealed class MainViewModel : ObservableObject
new("外观与资源管理器", "◈", ToolboxPage.Appearance),
new("Windows 更新", "↻", ToolboxPage.Updates),
new("安全与登录", "◇", ToolboxPage.Security),
new("桌面归档", "▣", ToolboxPage.Desktop),
new("目录整理", "▣", ToolboxPage.Desktop),
new("KMS 激活", "✓", ToolboxPage.Activation),
new("操作历史", "≡", ToolboxPage.History)
];
@@ -67,7 +73,7 @@ public sealed class MainViewModel : ObservableObject
_selectedKmsKey = KmsKeys.FirstOrDefault(x => x.EditionId == "Professional") ?? KmsKeys.FirstOrDefault();
NavigateCommand = new RelayCommand(parameter => Navigate((NavigationItemViewModel)parameter!));
RefreshCommand = new AsyncRelayCommand(_ => RefreshStatusAsync(), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(_ => RefreshStatusAsync(includeDefender: true, includeActivation: true), () => !IsBusy);
RefreshHistoryCommand = new AsyncRelayCommand(_ => RefreshHistoryAsync(), () => !IsBusy);
EnableClassicMenuCommand = Action(ToolboxCommandKind.EnableClassicContextMenu);
RestoreContextMenuCommand = Action(ToolboxCommandKind.RestoreContextMenu);
@@ -109,6 +115,17 @@ public sealed class MainViewModel : ObservableObject
ConfigureDesktopScheduleCommand = new AsyncRelayCommand(_ => ExecuteAsync(
ToolboxCommandKind.ConfigureDesktopSchedule,
DesktopArguments(includeSchedule: true)), () => !IsBusy);
TidyDownloadsCommand = new AsyncRelayCommand(_ => ExecuteAsync(
ToolboxCommandKind.TidyDownloads,
DownloadsArguments(),
"将在下载目录内按当天日期创建子目录,并把现有内容按类型分类移入。确定继续吗?"), () => !IsBusy);
UndoDownloadsTidyCommand = new AsyncRelayCommand(_ => ExecuteAsync(
ToolboxCommandKind.UndoDownloadsTidy,
DownloadsArguments(),
"将按最近一次整理清单把仍在分类目录中的项目移回下载目录。确定继续吗?"), () => !IsBusy);
ConfigureDownloadsScheduleCommand = new AsyncRelayCommand(_ => ExecuteAsync(
ToolboxCommandKind.ConfigureDownloadsSchedule,
DownloadsArguments(includeSchedule: true)), () => !IsBusy);
ActivateWindowsCommand = new AsyncRelayCommand(_ => ActivateAsync(), () => !IsBusy);
RestoreKmsCommand = new AsyncRelayCommand(_ => ExecuteAsync(
ToolboxCommandKind.RestoreKmsConfiguration,
@@ -143,6 +160,9 @@ public sealed class MainViewModel : ObservableObject
public ICommand ArchiveDesktopCommand { get; }
public ICommand UndoDesktopArchiveCommand { get; }
public ICommand ConfigureDesktopScheduleCommand { get; }
public ICommand TidyDownloadsCommand { get; }
public ICommand UndoDownloadsTidyCommand { get; }
public ICommand ConfigureDownloadsScheduleCommand { get; }
public ICommand ActivateWindowsCommand { get; }
public ICommand RestoreKmsCommand { get; }
public ICommand RestartExplorerCommand { get; }
@@ -187,6 +207,12 @@ public sealed class MainViewModel : ObservableObject
public string ActivationStatusText => _status?.ActivationStatus ?? "正在检测…";
public string KmsStatusText => _status is null ? string.Empty : $"KMS:{(_status.KmsHost ?? "未指定")} · 密钥尾号:{(_status.PartialProductKey ?? "未知")}";
public string AdvancedSourceText
{
get => _advancedSourceText;
private set => SetField(ref _advancedSourceText, value);
}
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); }
@@ -194,15 +220,64 @@ public sealed class MainViewModel : ObservableObject
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 bool DownloadsScheduleEnabled { get => _downloadsScheduleEnabled; set => SetField(ref _downloadsScheduleEnabled, value); }
public int DownloadsMonthlyDay { get => _downloadsMonthlyDay; set => SetField(ref _downloadsMonthlyDay, value); }
public string DownloadsScheduleTime { get => _downloadsScheduleTime; set => SetField(ref _downloadsScheduleTime, 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()
{
_statusCache = await JsonFileStore.LoadAsync<StatusCache>(StatusCachePath);
var tidySettings = LoadTidySettingsAsync();
await Task.WhenAll(
RefreshStatusAsync(showBusy: false),
RefreshHistoryAsync());
RefreshHistoryAsync(),
tidySettings);
_ = RefreshDefenderInBackgroundAsync();
}
private static string StatusCachePath => Path.Combine(ToolboxPaths.Cache, "status.json");
/// <summary>启动时回填已保存的归档配置,并以任务计划程序中的实际状态为准回填开关。</summary>
private async Task LoadTidySettingsAsync()
{
try
{
var profile = await _desktopService.LoadProfileAsync("default");
if (profile is not null)
{
DesktopDestination = profile.DestinationRoot;
SelectedMonthlyDay = profile.MonthlyDay;
ScheduleTime = profile.RunAt.ToString("HH:mm");
}
DesktopScheduleEnabled = await Task.Run(() => _desktopService.GetScheduleTaskEnabled("default")) ?? false;
var downloadsProfile = await _downloadsService.LoadProfileAsync();
if (downloadsProfile is not null)
{
DownloadsMonthlyDay = downloadsProfile.MonthlyDay;
DownloadsScheduleTime = downloadsProfile.RunAt.ToString("HH:mm");
}
DownloadsScheduleEnabled = await Task.Run(() => _downloadsService.GetScheduleTaskEnabled()) ?? false;
}
catch
{
// 回显失败不阻塞启动;用户仍可在页面中重新保存。
}
}
private async Task RefreshDefenderInBackgroundAsync()
{
try
{
await RefreshStatusAsync(preserveMessage: true, showBusy: false, includeDefender: true);
}
catch
{
// 后台刷新失败时保留缓存值。
}
}
private AsyncRelayCommand Action(ToolboxCommandKind kind) =>
@@ -215,6 +290,28 @@ public sealed class MainViewModel : ObservableObject
navigationItem.IsSelected = ReferenceEquals(navigationItem, item);
}
SelectedPage = item.Page;
if (item.Page == ToolboxPage.Activation)
{
_ = RefreshActivationOnDemandAsync();
}
}
/// <summary>激活状态查询较慢,只在进入 KMS 页面时按需刷新。</summary>
private async Task RefreshActivationOnDemandAsync()
{
if (IsBusy)
{
return;
}
try
{
await RefreshStatusAsync(preserveMessage: true, showBusy: false, includeActivation: true);
}
catch
{
// 按需刷新失败时保留当前显示。
}
}
private async Task<CommandResult?> ExecuteAsync(
@@ -247,11 +344,13 @@ public sealed class MainViewModel : ObservableObject
_lastSucceeded = restarted;
OnPropertyChanged(nameof(StatusBrush));
}
var refreshAdvanced = kind is ToolboxCommandKind.DisableDefenderProtection or
ToolboxCommandKind.RestoreDefenderProtection or
ToolboxCommandKind.ActivateWindowsKms or
var refreshDefender = kind is ToolboxCommandKind.DisableDefenderProtection or
ToolboxCommandKind.RestoreDefenderProtection;
var refreshActivation = kind is ToolboxCommandKind.ActivateWindowsKms or
ToolboxCommandKind.RestoreKmsConfiguration;
await RefreshStatusAsync(preserveMessage: true, includeAdvanced: refreshAdvanced);
await RefreshStatusAsync(preserveMessage: true,
includeDefender: refreshDefender,
includeActivation: refreshActivation);
await RefreshHistoryAsync();
return result;
}
@@ -268,7 +367,11 @@ public sealed class MainViewModel : ObservableObject
}
}
private async Task RefreshStatusAsync(bool preserveMessage = false, bool showBusy = true, bool includeAdvanced = true)
private async Task RefreshStatusAsync(
bool preserveMessage = false,
bool showBusy = true,
bool includeDefender = false,
bool includeActivation = false)
{
var wasBusy = IsBusy;
if (showBusy)
@@ -277,7 +380,44 @@ public sealed class MainViewModel : ObservableObject
}
try
{
_status = await _statusService.DetectAsync(includeAdvanced, _status);
var seed = _status ?? new SystemStatus
{
DefenderRealtimeProtectionEnabled = _statusCache?.DefenderRealtimeProtectionEnabled,
TamperProtectionEnabled = _statusCache?.TamperProtectionEnabled,
ActivationStatus = _statusCache?.ActivationStatus ?? "未知",
PartialProductKey = _statusCache?.PartialProductKey
};
var status = await _statusService.DetectAsync(includeDefender, seed);
if (includeActivation)
{
status = await _statusService.DetectActivationAsync(status);
}
_status = status;
if (includeDefender || includeActivation)
{
_statusCache = new StatusCache
{
UpdatedAt = DateTimeOffset.Now,
DefenderRealtimeProtectionEnabled = status.DefenderRealtimeProtectionEnabled,
TamperProtectionEnabled = status.TamperProtectionEnabled,
ActivationStatus = status.ActivationStatus,
PartialProductKey = status.PartialProductKey
};
try
{
await JsonFileStore.SaveAsync(StatusCachePath, _statusCache);
}
catch (IOException)
{
// 缓存写入失败只影响下次启动的首屏速度。
}
}
AdvancedSourceText = _statusCache is null
? string.Empty
: $"安全/激活状态检测于 {_statusCache.UpdatedAt:MM-dd HH:mm}";
if (!preserveMessage)
{
LastMessage = "系统状态已刷新";
@@ -292,6 +432,7 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(TamperStatusText));
OnPropertyChanged(nameof(ActivationStatusText));
OnPropertyChanged(nameof(KmsStatusText));
OnPropertyChanged(nameof(AdvancedSourceText));
if (!string.IsNullOrWhiteSpace(_status.KmsHost))
{
@@ -356,6 +497,21 @@ public sealed class MainViewModel : ObservableObject
return arguments;
}
private Dictionary<string, string> DownloadsArguments(bool includeSchedule = false)
{
var arguments = new Dictionary<string, string>
{
["profile"] = DownloadsTidyService.ProfileId
};
if (includeSchedule)
{
arguments["enabled"] = DownloadsScheduleEnabled.ToString();
arguments["day"] = DownloadsMonthlyDay.ToString();
arguments["time"] = DownloadsScheduleTime;
}
return arguments;
}
private async Task ActivateAsync()
{
if (SelectedKmsKey is null)
@@ -480,6 +636,9 @@ public sealed class HistoryItemViewModel(OperationLogEntry entry)
ToolboxCommandKind.ArchiveDesktop => "桌面归档",
ToolboxCommandKind.UndoDesktopArchive => "撤销桌面归档",
ToolboxCommandKind.ConfigureDesktopSchedule => "桌面归档计划",
ToolboxCommandKind.TidyDownloads => "下载目录整理",
ToolboxCommandKind.UndoDownloadsTidy => "撤销下载整理",
ToolboxCommandKind.ConfigureDownloadsSchedule => "下载整理计划",
ToolboxCommandKind.ActivateWindowsKms => "KMS 激活",
ToolboxCommandKind.RestoreKmsConfiguration => "恢复 KMS 配置",
_ => action
+16 -1
View File
@@ -16,5 +16,20 @@ if (args.Length >= 3 &&
return result.Success ? 0 : 1;
}
Console.Error.WriteLine("用法: WindowsToolbox.Cli desktop-archive --profile <id>");
if (args.Length >= 3 &&
args[0].Equals("downloads-tidy", 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.TidyDownloads,
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> | downloads-tidy --profile <id>");
return 2;
+21
View File
@@ -21,6 +21,9 @@ public enum ToolboxCommandKind
ArchiveDesktop,
UndoDesktopArchive,
ConfigureDesktopSchedule,
TidyDownloads,
UndoDownloadsTidy,
ConfigureDownloadsSchedule,
ActivateWindowsKms,
RestoreKmsConfiguration
}
@@ -102,6 +105,14 @@ public sealed record DesktopArchiveProfile
public TimeOnly RunAt { get; init; } = new(9, 0);
}
public sealed record DownloadsTidyProfile
{
public string Id { get; init; } = "downloads";
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
@@ -143,6 +154,15 @@ public static class JsonDefaults
};
}
public sealed record StatusCache
{
public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.Now;
public bool? DefenderRealtimeProtectionEnabled { get; init; }
public bool? TamperProtectionEnabled { get; init; }
public string ActivationStatus { get; init; } = "未知";
public string? PartialProductKey { get; init; }
}
public static class ToolboxPaths
{
public static string UserRoot => Path.Combine(
@@ -157,6 +177,7 @@ public static class ToolboxPaths
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 Cache => Path.Combine(UserRoot, "Cache");
public static string Snapshots => Path.Combine(MachineRoot, "Snapshots");
public static string Assets => Path.Combine(MachineRoot, "Assets");
}
@@ -0,0 +1,45 @@
namespace WindowsToolbox.Infrastructure;
/// <summary>
/// 桌面归档与下载目录整理共用的文件系统操作。
/// </summary>
internal static class ArchiveFileOps
{
/// <summary>判断项是否属于整理时必须原地保留的系统或特殊项。</summary>
public static bool IsProtectedSystemItem(string name, FileAttributes attributes, bool isDirectory)
{
return name.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase) ||
attributes.HasFlag(FileAttributes.System) ||
isDirectory && attributes.HasFlag(FileAttributes.ReparsePoint);
}
public 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} 生成不冲突的文件名");
}
public 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);
}
}
@@ -28,6 +28,10 @@ public sealed class DesktopArchiveService
public string GetDesktopPath() => _desktopPathProvider();
/// <summary>查询计划任务实际状态;null 表示任务不存在。</summary>
public bool? GetScheduleTaskEnabled(string id = "default") =>
_taskScheduler.TryGetEnabled($@"\WindowsToolbox\DesktopArchive-{id}");
public async Task SaveProfileAsync(DesktopArchiveProfile profile, CancellationToken cancellationToken = default)
{
ValidateProfile(profile);
@@ -90,13 +94,13 @@ public sealed class DesktopArchiveService
continue;
}
target = GetUniquePath(target, isDirectory);
target = ArchiveFileOps.GetUniquePath(target, isDirectory);
}
var length = isDirectory ? 0 : new FileInfo(source).Length;
if (isDirectory)
{
MoveDirectory(source, target);
ArchiveFileOps.MoveDirectory(source, target);
}
else
{
@@ -140,24 +144,15 @@ public sealed class DesktopArchiveService
{
try
{
if (!Directory.Exists(_manifestsRoot))
var manifest = await FindLatestManifestAsync(command, cancellationToken);
if (manifest is null)
{
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)
foreach (var item in manifest.Value.Manifest.Files)
{
try
{
@@ -170,10 +165,10 @@ public sealed class DesktopArchiveService
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;
var target = occupied ? ArchiveFileOps.GetUniquePath(item.OriginalPath, item.IsDirectory) : item.OriginalPath;
if (item.IsDirectory)
{
MoveDirectory(item.ArchivedPath, target);
ArchiveFileOps.MoveDirectory(item.ArchivedPath, target);
}
else
{
@@ -189,7 +184,7 @@ public sealed class DesktopArchiveService
if (errors.Count == 0)
{
File.Delete(path);
File.Delete(manifest.Value.Path);
}
var summary = $"已恢复 {restored} 个项目,失败 {errors.Count} 个";
@@ -208,6 +203,27 @@ public sealed class DesktopArchiveService
}
}
private async Task<(string Path, ArchiveManifest Manifest)?> FindLatestManifestAsync(
ToolboxCommand command, CancellationToken cancellationToken)
{
if (!Directory.Exists(_manifestsRoot))
{
return null;
}
var profileId = command.Arguments.GetValueOrDefault("profile", "default");
foreach (var path in Directory.EnumerateFiles(_manifestsRoot, "*.json").OrderByDescending(Path.GetFileName))
{
var manifest = await JsonFileStore.LoadAsync<ArchiveManifest>(path, cancellationToken);
if (manifest is not null && manifest.ProfileId == profileId)
{
return (path, manifest);
}
}
return null;
}
public async Task<CommandResult> ConfigureScheduleAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
{
try
@@ -281,36 +297,6 @@ public sealed class DesktopArchiveService
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);
@@ -0,0 +1,337 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using WindowsToolbox.Core;
namespace WindowsToolbox.Infrastructure;
public sealed partial class DownloadsTidyService
{
public const string ProfileId = "downloads";
private readonly TaskSchedulerService _taskScheduler;
private readonly Func<string> _downloadsPathProvider;
private readonly string _profilesRoot;
private readonly string _manifestsRoot;
private static readonly (string Category, string[] Extensions)[] Categories =
[
("安装包", [".exe", ".msi", ".msix", ".appx"]),
("压缩包", [".zip", ".rar", ".7z", ".tar", ".gz"]),
("文档", [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md"]),
("图片", [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]),
("视频", [".mp4", ".mkv", ".avi", ".mov"]),
("音频", [".mp3", ".wav", ".flac"])
];
/// <summary>浏览器下载未完成的临时文件;移动会中断正在进行的下载。</summary>
private static readonly HashSet<string> InProgressExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".crdownload", ".part", ".partial", ".opdownload", ".download"
};
public DownloadsTidyService(
TaskSchedulerService taskScheduler,
Func<string>? downloadsPathProvider = null,
string? dataRoot = null)
{
_taskScheduler = taskScheduler;
_downloadsPathProvider = downloadsPathProvider ?? (() => KnownFolderPath(FolderIds.Downloads));
_profilesRoot = dataRoot is null ? ToolboxPaths.Profiles : Path.Combine(dataRoot, "Profiles");
_manifestsRoot = dataRoot is null ? ToolboxPaths.Manifests : Path.Combine(dataRoot, "ArchiveManifests");
}
public string GetDownloadsPath() => _downloadsPathProvider();
public async Task SaveProfileAsync(DownloadsTidyProfile profile, CancellationToken cancellationToken = default)
{
ValidateProfile(profile);
await JsonFileStore.SaveAsync(ProfilePath(profile.Id), profile, cancellationToken);
}
public Task<DownloadsTidyProfile?> LoadProfileAsync(string id = ProfileId, CancellationToken cancellationToken = default) =>
JsonFileStore.LoadAsync<DownloadsTidyProfile>(ProfilePath(id), cancellationToken);
/// <summary>查询计划任务实际状态;null 表示任务不存在。</summary>
public bool? GetScheduleTaskEnabled(string id = ProfileId) =>
_taskScheduler.TryGetEnabled($@"\WindowsToolbox\{TaskName(id)}");
public Task<CommandResult> TidyAsync(ToolboxCommand command, CancellationToken cancellationToken = default) =>
Task.Run(() => TidyCoreAsync(command, cancellationToken), cancellationToken);
private async Task<CommandResult> TidyCoreAsync(ToolboxCommand command, CancellationToken cancellationToken)
{
try
{
var profile = await LoadProfileAsync(ProfileId, cancellationToken) ?? new DownloadsTidyProfile();
ValidateProfile(profile);
var downloads = Path.GetFullPath(GetDownloadsPath());
if (!Directory.Exists(downloads))
{
throw new DirectoryNotFoundException("没有找到下载目录");
}
var dateName = DateTime.Now.ToString("yyyy-MM-dd");
var dateFolder = Path.Combine(downloads, dateName);
var pending = new List<(string Source, string Name, bool IsDirectory)>();
foreach (var source in Directory.EnumerateFileSystemEntries(downloads, "*", SearchOption.TopDirectoryOnly))
{
var name = Path.GetFileName(source);
var attributes = File.GetAttributes(source);
var isDirectory = attributes.HasFlag(FileAttributes.Directory);
if (isDirectory && DateFolderNameRegex().IsMatch(name) ||
ArchiveFileOps.IsProtectedSystemItem(name, attributes, isDirectory) ||
!isDirectory && InProgressExtensions.Contains(Path.GetExtension(source)))
{
continue;
}
pending.Add((source, name, isDirectory));
}
if (pending.Count == 0)
{
return Result(command, true, "下载目录没有需要整理的内容", []);
}
Directory.CreateDirectory(dateFolder);
var manifest = new ArchiveManifest
{
Id = Guid.NewGuid().ToString("N"),
ProfileId = ProfileId,
DestinationFolder = dateFolder
};
var manifestPath = Path.Combine(_manifestsRoot, $"{manifest.CreatedAt:yyyyMMdd-HHmmssfffffff}-{manifest.Id}.json");
await JsonFileStore.SaveAsync(manifestPath, manifest, cancellationToken);
foreach (var (source, name, isDirectory) in pending)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var target = isDirectory
? Path.Combine(dateFolder, name)
: Path.Combine(dateFolder, CategoryFor(name), name);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
if (File.Exists(target) || Directory.Exists(target))
{
if (!profile.RenameOnCollision)
{
manifest.Skipped.Add(source);
continue;
}
target = ArchiveFileOps.GetUniquePath(target, isDirectory);
}
var length = isDirectory ? 0 : new FileInfo(source).Length;
if (isDirectory)
{
ArchiveFileOps.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 Result(command, success, summary,
[
new CommandStepResult("下载目录整理", success, summary),
new CommandStepResult("分类规则", true, $"文件按类型进入子目录;文件夹进入 {dateName} 根目录"),
new CommandStepResult("恢复清单", true, manifestPath)
]);
}
catch (Exception ex)
{
return Result(command, false, ex.Message, [new CommandStepResult("下载目录整理", false, 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
{
var manifest = await FindLatestManifestAsync(cancellationToken);
if (manifest is null)
{
return Result(command, false, "没有可恢复的下载目录整理记录",
[new CommandStepResult("恢复下载整理", false, "没有可恢复的整理记录")]);
}
var restored = 0;
var errors = new List<string>();
foreach (var item in manifest.Value.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 ? ArchiveFileOps.GetUniquePath(item.OriginalPath, item.IsDirectory) : item.OriginalPath;
if (item.IsDirectory)
{
ArchiveFileOps.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(manifest.Value.Path);
}
var summary = $"已恢复 {restored} 个项目,失败 {errors.Count} 个";
return Result(command, errors.Count == 0, summary,
[new CommandStepResult("恢复下载整理", errors.Count == 0, summary)]);
}
catch (Exception ex)
{
return Result(command, false, ex.Message, [new CommandStepResult("恢复下载整理", false, 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 LoadProfileAsync(ProfileId, cancellationToken) ?? new DownloadsTidyProfile();
if (command.Arguments.TryGetValue("day", out var dayText) && int.TryParse(dayText, out var day))
{
profile = profile with { MonthlyDay = day };
}
if (command.Arguments.TryGetValue("time", out var timeText) && TimeOnly.TryParse(timeText, out var time))
{
profile = profile with { RunAt = time };
}
ValidateProfile(profile);
await SaveProfileAsync(profile, cancellationToken);
if (enabled)
{
var cli = CompanionLocator.Find("WindowsToolbox.Cli.exe");
_taskScheduler.RegisterMonthlyTask(TaskName(profile.Id), cli, $"downloads-tidy --profile {profile.Id}", profile.MonthlyDay, profile.RunAt);
}
else
{
_taskScheduler.DeleteToolboxTask(TaskName(profile.Id));
}
var summary = enabled ? "下载目录整理计划任务已创建" : "下载目录整理计划任务已删除";
return Result(command, true, summary, [new CommandStepResult("计划任务", true, summary)]);
}
catch (Exception ex)
{
return Result(command, false, ex.Message, [new CommandStepResult("计划任务", false, ex.Message)]);
}
}
private async Task<(string Path, ArchiveManifest Manifest)?> FindLatestManifestAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(_manifestsRoot))
{
return null;
}
foreach (var path in Directory.EnumerateFiles(_manifestsRoot, "*.json").OrderByDescending(Path.GetFileName))
{
var manifest = await JsonFileStore.LoadAsync<ArchiveManifest>(path, cancellationToken);
if (manifest is not null && manifest.ProfileId == ProfileId)
{
return (path, manifest);
}
}
return null;
}
private static string CategoryFor(string fileName)
{
var extension = Path.GetExtension(fileName);
var category = Categories.FirstOrDefault(entry => entry.Extensions.Contains(extension, StringComparer.OrdinalIgnoreCase));
return category.Category ?? "其他";
}
private static string TaskName(string profileId) => $"DownloadsTidy-{profileId}";
private string ProfilePath(string id) => Path.Combine(_profilesRoot, $"downloads-{id}.json");
private static void ValidateProfile(DownloadsTidyProfile profile)
{
if (profile.MonthlyDay is < 1 or > 28)
{
throw new ArgumentOutOfRangeException(nameof(profile.MonthlyDay), "每月执行日期必须在 1 到 28 之间");
}
}
private static CommandResult Result(ToolboxCommand command, bool success, string summary, List<CommandStepResult> steps) => new()
{
CommandId = command.Id,
Kind = command.Kind,
Success = success,
Summary = summary,
Steps = steps
};
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.MyDocuments);
}
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 Downloads = new("374DE290-123F-4565-9164-39C4925E467B");
}
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$", RegexOptions.CultureInvariant)]
private static partial Regex DateFolderNameRegex();
}
@@ -15,7 +15,7 @@ public sealed class SystemStatusService(RegistryStore registry, ProcessRunner pr
private const string SppKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\SoftwareProtectionPlatform";
public async Task<SystemStatus> DetectAsync(
bool includeAdvanced = true,
bool includeDefender = true,
SystemStatus? previous = null,
CancellationToken cancellationToken = default)
{
@@ -42,25 +42,20 @@ public sealed class SystemStatusService(RegistryStore registry, ProcessRunner pr
var defenderEnabled = previous?.DefenderRealtimeProtectionEnabled;
var tamperEnabled = previous?.TamperProtectionEnabled;
var activationStatus = previous?.ActivationStatus ?? "未知";
var partialProductKey = previous?.PartialProductKey;
if (includeAdvanced)
if (includeDefender)
{
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};" +
using var defenderTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
defenderTimeout.CancelAfter(TimeSpan.FromSeconds(5));
const string defenderScript =
"$r=[ordered]@{Realtime=$null;Tamper=$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))
var defender = await processes.RunPowerShellAsync(defenderScript, defenderTimeout.Token);
if (defender.Success && !string.IsNullOrWhiteSpace(defender.StandardOutput))
{
using var document = JsonDocument.Parse(advanced.StandardOutput);
using var document = JsonDocument.Parse(defender.StandardOutput);
var root = document.RootElement;
if (root.TryGetProperty("Realtime", out var realtime) && realtime.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
@@ -70,31 +65,15 @@ public sealed class SystemStatusService(RegistryStore registry, ProcessRunner pr
{
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.
// Defender 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.
// Preserve previous values if the Defender provider is unavailable.
}
}
@@ -113,12 +92,65 @@ public sealed class SystemStatusService(RegistryStore registry, ProcessRunner pr
DefenderRealtimeProtectionEnabled = defenderEnabled,
TamperProtectionEnabled = tamperEnabled,
AutoLogonOptionPrepared = Convert.ToInt32(registry.Read("HKLM", AutoLogonKey, "DevicePasswordLessBuildVersion") ?? 2) == 0,
ActivationStatus = activationStatus,
PartialProductKey = partialProductKey,
ActivationStatus = previous?.ActivationStatus ?? "未知",
PartialProductKey = previous?.PartialProductKey,
KmsHost = registry.Read("HKLM", SppKey, "KeyManagementServiceName")?.ToString()
};
}
public async Task<SystemStatus> DetectActivationAsync(
SystemStatus seed,
CancellationToken cancellationToken = default)
{
var activationStatus = seed.ActivationStatus;
var partialProductKey = seed.PartialProductKey;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
const string activationScript =
"$r=[ordered]@{Status=$null;Key=$null};" +
"try{$p=Get-CimInstance SoftwareLicensingProduct -Filter \"Name like 'Windows%'\" -ErrorAction Stop|" +
"Where-Object {$_.PartialProductKey}|Select-Object -First 1;" +
"if($null-ne $p){$r.Status=[int]$p.LicenseStatus;$r.Key=$p.PartialProductKey}}catch{};" +
"[pscustomobject]$r|ConvertTo-Json -Compress";
var activation = await processes.RunPowerShellAsync(activationScript, timeout.Token);
if (activation.Success && !string.IsNullOrWhiteSpace(activation.StandardOutput))
{
using var document = JsonDocument.Parse(activation.StandardOutput);
var root = document.RootElement;
if (root.TryGetProperty("Status", out var status) && status.ValueKind == JsonValueKind.Number)
{
activationStatus = MapLicenseStatus(status.GetInt32());
}
partialProductKey = root.TryGetProperty("Key", out var key) && key.ValueKind == JsonValueKind.String
? key.GetString()
: partialProductKey;
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Keep the seed values when licensing query times out.
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// Keep the seed values when the licensing provider is unavailable.
}
return seed with { ActivationStatus = activationStatus, PartialProductKey = partialProductKey };
}
public static string MapLicenseStatus(int licenseStatus) => licenseStatus switch
{
1 => "已激活",
2 => "初始宽限期",
3 => "额外宽限期",
4 => "非正版宽限期",
5 => "通知模式",
6 => "延长宽限期",
_ => "未激活"
};
public static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
@@ -9,6 +9,7 @@ public sealed class ToolboxCommandDispatcher
private readonly WindowsSecurityService _security;
private readonly AutoLogonService _autoLogon;
private readonly DesktopArchiveService _desktop;
private readonly DownloadsTidyService _downloads;
private readonly ActivationService _activation;
private readonly OperationLogStore _logs;
@@ -23,6 +24,7 @@ public sealed class ToolboxCommandDispatcher
_security = new WindowsSecurityService(registry, snapshots, processes);
_autoLogon = new AutoLogonService(registry, snapshots);
_desktop = new DesktopArchiveService(tasks);
_downloads = new DownloadsTidyService(tasks);
_activation = new ActivationService(registry, snapshots, processes);
_logs = new OperationLogStore();
}
@@ -31,6 +33,9 @@ public sealed class ToolboxCommandDispatcher
(ToolboxCommandKind.ArchiveDesktop or
ToolboxCommandKind.UndoDesktopArchive or
ToolboxCommandKind.ConfigureDesktopSchedule or
ToolboxCommandKind.TidyDownloads or
ToolboxCommandKind.UndoDownloadsTidy or
ToolboxCommandKind.ConfigureDownloadsSchedule or
ToolboxCommandKind.DetectStatus);
public async Task<CommandResult> ExecuteAsync(ToolboxCommand command, CancellationToken cancellationToken = default)
@@ -82,6 +87,9 @@ public sealed class ToolboxCommandDispatcher
ToolboxCommandKind.ArchiveDesktop => await _desktop.ArchiveAsync(command, cancellationToken),
ToolboxCommandKind.UndoDesktopArchive => await _desktop.UndoLatestAsync(command, cancellationToken),
ToolboxCommandKind.ConfigureDesktopSchedule => await _desktop.ConfigureScheduleAsync(command, cancellationToken),
ToolboxCommandKind.TidyDownloads => await _downloads.TidyAsync(command, cancellationToken),
ToolboxCommandKind.UndoDownloadsTidy => await _downloads.UndoLatestAsync(command, cancellationToken),
ToolboxCommandKind.ConfigureDownloadsSchedule => await _downloads.ConfigureScheduleAsync(command, cancellationToken),
ToolboxCommandKind.ActivateWindowsKms => await _activation.ActivateAsync(command, cancellationToken),
ToolboxCommandKind.RestoreKmsConfiguration => await _activation.RestoreAsync(command, cancellationToken),
_ => new CommandResult
+86 -2
View File
@@ -85,7 +85,8 @@ try
var undo = await desktopService.UndoLatestAsync(new ToolboxCommand
{
Id = Guid.NewGuid().ToString("N"),
Kind = ToolboxCommandKind.UndoDesktopArchive
Kind = ToolboxCommandKind.UndoDesktopArchive,
Arguments = new Dictionary<string, string> { ["profile"] = "verification" }
});
Check(undo.Success, "撤销最近桌面归档失败");
Check(File.Exists(Path.Combine(testDesktop, "Project", "third.txt")), "撤销没有恢复最近一次文件夹");
@@ -93,6 +94,89 @@ try
Check(File.Exists(Path.Combine(dateFolder, "report (1).txt")), "撤销文件夹归档不应影响更早归档文件");
Check(Directory.Exists(Path.Combine(dateFolder, "Project")), "撤销最近一次不应影响更早归档文件夹");
Check(Directory.Exists(Path.Combine(dateFolder, "Project (1)")), "撤销最近一次不应影响第二次归档文件夹");
// 下载目录整理:分类、排除、二次整理合并、撤销与 profile 过滤。
var testDownloads = Path.Combine(temporaryRoot, "Downloads");
Directory.CreateDirectory(testDownloads);
File.WriteAllText(Path.Combine(testDownloads, "setup.exe"), "exe");
File.WriteAllText(Path.Combine(testDownloads, "report.pdf"), "pdf");
File.WriteAllText(Path.Combine(testDownloads, "photo.png"), "png");
File.WriteAllText(Path.Combine(testDownloads, "unknown.xyz"), "xyz");
File.WriteAllText(Path.Combine(testDownloads, "movie.mp4"), "mp4");
File.WriteAllText(Path.Combine(testDownloads, "half.crdownload"), "downloading");
File.WriteAllText(Path.Combine(testDownloads, "desktop.ini"), "ini");
var extractedFolder = Path.Combine(testDownloads, "Extracted");
Directory.CreateDirectory(extractedFolder);
File.WriteAllText(Path.Combine(extractedFolder, "inner.txt"), "inner");
var oldDateFolder = Path.Combine(testDownloads, "2020-01-01");
Directory.CreateDirectory(oldDateFolder);
var downloadsService = new DownloadsTidyService(
new TaskSchedulerService(),
() => testDownloads,
Path.Combine(temporaryRoot, "Data"));
var tidyCommand = new ToolboxCommand
{
Id = Guid.NewGuid().ToString("N"),
Kind = ToolboxCommandKind.TidyDownloads,
Arguments = new Dictionary<string, string> { ["profile"] = "downloads" }
};
var firstTidy = await downloadsService.TidyAsync(tidyCommand);
var downloadsDateFolder = Path.Combine(testDownloads, DateTime.Now.ToString("yyyy-MM-dd"));
Check(firstTidy.Success, "第一次下载目录整理失败");
Check(File.Exists(Path.Combine(downloadsDateFolder, "安装包", "setup.exe")), "安装包没有进入类型目录");
Check(File.Exists(Path.Combine(downloadsDateFolder, "文档", "report.pdf")), "文档没有进入类型目录");
Check(File.Exists(Path.Combine(downloadsDateFolder, "图片", "photo.png")), "图片没有进入类型目录");
Check(File.Exists(Path.Combine(downloadsDateFolder, "视频", "movie.mp4")), "视频没有进入类型目录");
Check(File.Exists(Path.Combine(downloadsDateFolder, "其他", "unknown.xyz")), "未识别类型没有进入其他目录");
Check(Directory.Exists(Path.Combine(downloadsDateFolder, "Extracted")), "文件夹没有进入日期目录根");
Check(File.Exists(Path.Combine(testDownloads, "half.crdownload")), "下载中的临时文件不应被移动");
Check(File.Exists(Path.Combine(testDownloads, "desktop.ini")), "desktop.ini 不应被移动");
Check(Directory.Exists(oldDateFolder), "日期格式的旧整理目录不应被移动");
await Task.Delay(5);
File.WriteAllText(Path.Combine(testDownloads, "setup.exe"), "exe-second");
File.WriteAllText(Path.Combine(testDownloads, "report.pdf"), "pdf-second");
var secondTidy = await downloadsService.TidyAsync(tidyCommand with { Id = Guid.NewGuid().ToString("N") });
Check(secondTidy.Success, "第二次下载目录整理失败");
Check(File.Exists(Path.Combine(downloadsDateFolder, "安装包", "setup (1).exe")), "同日同名文件没有自动编号");
Check(File.Exists(Path.Combine(downloadsDateFolder, "文档", "report (1).pdf")), "同日同名文档没有自动编号");
var emptyDownloads = Path.Combine(temporaryRoot, "EmptyDownloads");
Directory.CreateDirectory(emptyDownloads);
var emptyService = new DownloadsTidyService(
new TaskSchedulerService(),
() => emptyDownloads,
Path.Combine(temporaryRoot, "Data"));
var emptyTidy = await emptyService.TidyAsync(tidyCommand with { Id = Guid.NewGuid().ToString("N") });
Check(emptyTidy.Success, "空目录整理应成功返回");
Check(!Directory.Exists(Path.Combine(emptyDownloads, DateTime.Now.ToString("yyyy-MM-dd"))), "空目录整理不应创建日期目录");
// 撤销按 profile 过滤:先制造一个更新的桌面清单,下载撤销必须仍找下载清单。
File.WriteAllText(Path.Combine(testDesktop, "filter-test.txt"), "filter");
var desktopFilterArchive = await desktopService.ArchiveAsync(archiveCommand with { Id = Guid.NewGuid().ToString("N") });
Check(desktopFilterArchive.Success, "桌面归档(过滤测试前置)失败");
var undoDownloads = await downloadsService.UndoLatestAsync(new ToolboxCommand
{
Id = Guid.NewGuid().ToString("N"),
Kind = ToolboxCommandKind.UndoDownloadsTidy
});
Check(undoDownloads.Success, "撤销最近一次下载整理失败");
Check(File.Exists(Path.Combine(testDownloads, "setup.exe")), "撤销没有恢复最近整理的文件");
Check(File.Exists(Path.Combine(testDownloads, "report.pdf")), "撤销没有恢复最近整理的文档");
Check(File.Exists(Path.Combine(downloadsDateFolder, "安装包", "setup.exe")), "撤销最近一次不应影响更早整理的文件");
Check(Directory.Exists(Path.Combine(downloadsDateFolder, "Extracted")), "撤销最近一次不应影响更早整理的文件夹");
Check(File.Exists(Path.Combine(dateFolder, "filter-test.txt")), "下载撤销不应影响桌面归档内容");
var undoDesktopAfterFilter = await desktopService.UndoLatestAsync(new ToolboxCommand
{
Id = Guid.NewGuid().ToString("N"),
Kind = ToolboxCommandKind.UndoDesktopArchive,
Arguments = new Dictionary<string, string> { ["profile"] = "verification" }
});
Check(undoDesktopAfterFilter.Success, "下载撤销后桌面撤销失败");
Check(File.Exists(Path.Combine(testDesktop, "filter-test.txt")), "桌面撤销没有恢复过滤测试文件");
}
finally
{
@@ -112,7 +196,7 @@ if (failures.Count > 0)
return 1;
}
Console.WriteLine($"验证通过:{catalog.Count} 个 KMS 版本条目;命令模型、权限边界、文件与文件夹归档、系统项排除、同名处理和撤销正常。");
Console.WriteLine($"验证通过:{catalog.Count} 个 KMS 版本条目;命令模型、权限边界、桌面归档与撤销、下载目录分类整理、排除规则、同日合并和按 profile 撤销正常。");
return 0;
void Check(bool condition, string message)