diff --git a/README.md b/README.md
index b0e9071..c0914d3 100644
--- a/README.md
+++ b/README.md
@@ -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\ 透明快捷方式图标
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
index 86c296d..9aed406 100644
--- a/docs/OPERATIONS.md
+++ b/docs/OPERATIONS.md
@@ -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`,可以手动维护。激活顺序为:
diff --git a/src/WindowsToolbox.App/MainWindow.xaml b/src/WindowsToolbox.App/MainWindow.xaml
index 5c32741..7c90932 100644
--- a/src/WindowsToolbox.App/MainWindow.xaml
+++ b/src/WindowsToolbox.App/MainWindow.xaml
@@ -130,6 +130,8 @@
+
+
@@ -241,11 +243,12 @@
-
-
+
+
-
+
+
@@ -259,7 +262,7 @@
-
+
@@ -275,6 +278,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WindowsToolbox.App/ViewModels.cs b/src/WindowsToolbox.App/ViewModels.cs
index aa0fb41..03c4bf4 100644
--- a/src/WindowsToolbox.App/ViewModels.cs
+++ b/src/WindowsToolbox.App/ViewModels.cs
@@ -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(StatusCachePath);
+ var tidySettings = LoadTidySettingsAsync();
await Task.WhenAll(
RefreshStatusAsync(showBusy: false),
- RefreshHistoryAsync());
+ RefreshHistoryAsync(),
+ tidySettings);
+ _ = RefreshDefenderInBackgroundAsync();
+ }
+
+ private static string StatusCachePath => Path.Combine(ToolboxPaths.Cache, "status.json");
+
+ /// 启动时回填已保存的归档配置,并以任务计划程序中的实际状态为准回填开关。
+ 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();
+ }
+ }
+
+ /// 激活状态查询较慢,只在进入 KMS 页面时按需刷新。
+ private async Task RefreshActivationOnDemandAsync()
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ try
+ {
+ await RefreshStatusAsync(preserveMessage: true, showBusy: false, includeActivation: true);
+ }
+ catch
+ {
+ // 按需刷新失败时保留当前显示。
+ }
}
private async Task 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 DownloadsArguments(bool includeSchedule = false)
+ {
+ var arguments = new Dictionary
+ {
+ ["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
diff --git a/src/WindowsToolbox.Cli/Program.cs b/src/WindowsToolbox.Cli/Program.cs
index 3674b0d..f7cf28f 100644
--- a/src/WindowsToolbox.Cli/Program.cs
+++ b/src/WindowsToolbox.Cli/Program.cs
@@ -16,5 +16,20 @@ if (args.Length >= 3 &&
return result.Success ? 0 : 1;
}
-Console.Error.WriteLine("用法: WindowsToolbox.Cli desktop-archive --profile ");
+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 { ["profile"] = args[2] }
+ });
+ Console.WriteLine(result.Summary);
+ return result.Success ? 0 : 1;
+}
+
+Console.Error.WriteLine("用法: WindowsToolbox.Cli desktop-archive --profile | downloads-tidy --profile ");
return 2;
diff --git a/src/WindowsToolbox.Core/Models.cs b/src/WindowsToolbox.Core/Models.cs
index fa1d7ed..6c6fb72 100644
--- a/src/WindowsToolbox.Core/Models.cs
+++ b/src/WindowsToolbox.Core/Models.cs
@@ -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");
}
diff --git a/src/WindowsToolbox.Infrastructure/ArchiveFileOps.cs b/src/WindowsToolbox.Infrastructure/ArchiveFileOps.cs
new file mode 100644
index 0000000..a00a390
--- /dev/null
+++ b/src/WindowsToolbox.Infrastructure/ArchiveFileOps.cs
@@ -0,0 +1,45 @@
+namespace WindowsToolbox.Infrastructure;
+
+///
+/// 桌面归档与下载目录整理共用的文件系统操作。
+///
+internal static class ArchiveFileOps
+{
+ /// 判断项是否属于整理时必须原地保留的系统或特殊项。
+ 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);
+ }
+}
diff --git a/src/WindowsToolbox.Infrastructure/DesktopArchiveService.cs b/src/WindowsToolbox.Infrastructure/DesktopArchiveService.cs
index 6777936..7598d1e 100644
--- a/src/WindowsToolbox.Infrastructure/DesktopArchiveService.cs
+++ b/src/WindowsToolbox.Infrastructure/DesktopArchiveService.cs
@@ -28,6 +28,10 @@ public sealed class DesktopArchiveService
public string GetDesktopPath() => _desktopPathProvider();
+ /// 查询计划任务实际状态;null 表示任务不存在。
+ 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(path, cancellationToken)
- ?? throw new InvalidDataException("归档恢复清单损坏");
var restored = 0;
var errors = new List();
- 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(path, cancellationToken);
+ if (manifest is not null && manifest.ProfileId == profileId)
+ {
+ return (path, manifest);
+ }
+ }
+
+ return null;
+ }
+
public async Task 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);
diff --git a/src/WindowsToolbox.Infrastructure/DownloadsTidyService.cs b/src/WindowsToolbox.Infrastructure/DownloadsTidyService.cs
new file mode 100644
index 0000000..1267c00
--- /dev/null
+++ b/src/WindowsToolbox.Infrastructure/DownloadsTidyService.cs
@@ -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 _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"])
+ ];
+
+ /// 浏览器下载未完成的临时文件;移动会中断正在进行的下载。
+ private static readonly HashSet InProgressExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".crdownload", ".part", ".partial", ".opdownload", ".download"
+ };
+
+ public DownloadsTidyService(
+ TaskSchedulerService taskScheduler,
+ Func? 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 LoadProfileAsync(string id = ProfileId, CancellationToken cancellationToken = default) =>
+ JsonFileStore.LoadAsync(ProfilePath(id), cancellationToken);
+
+ /// 查询计划任务实际状态;null 表示任务不存在。
+ public bool? GetScheduleTaskEnabled(string id = ProfileId) =>
+ _taskScheduler.TryGetEnabled($@"\WindowsToolbox\{TaskName(id)}");
+
+ public Task TidyAsync(ToolboxCommand command, CancellationToken cancellationToken = default) =>
+ Task.Run(() => TidyCoreAsync(command, cancellationToken), cancellationToken);
+
+ private async Task 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 UndoLatestAsync(ToolboxCommand command, CancellationToken cancellationToken = default) =>
+ Task.Run(() => UndoLatestCoreAsync(command, cancellationToken), cancellationToken);
+
+ private async Task 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();
+ 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 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(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 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();
+}
diff --git a/src/WindowsToolbox.Infrastructure/SystemStatusService.cs b/src/WindowsToolbox.Infrastructure/SystemStatusService.cs
index 78c7a2e..07e930d 100644
--- a/src/WindowsToolbox.Infrastructure/SystemStatusService.cs
+++ b/src/WindowsToolbox.Infrastructure/SystemStatusService.cs
@@ -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 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 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();
diff --git a/src/WindowsToolbox.Infrastructure/ToolboxCommandDispatcher.cs b/src/WindowsToolbox.Infrastructure/ToolboxCommandDispatcher.cs
index d99b004..f574df9 100644
--- a/src/WindowsToolbox.Infrastructure/ToolboxCommandDispatcher.cs
+++ b/src/WindowsToolbox.Infrastructure/ToolboxCommandDispatcher.cs
@@ -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 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
diff --git a/tests/WindowsToolbox.Verification/Program.cs b/tests/WindowsToolbox.Verification/Program.cs
index a9a4838..253ad23 100644
--- a/tests/WindowsToolbox.Verification/Program.cs
+++ b/tests/WindowsToolbox.Verification/Program.cs
@@ -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 { ["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 { ["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 { ["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)