新增下载目录整理、修复计划任务状态回显并优化启动检测速度
- 下载目录整理:按当天日期在下载目录内建子目录,文件按类型分类(安装包/压缩包/文档/图片/视频/音频/其他),文件夹进日期目录根;跳过下载中临时文件、日期格式旧目录和系统项;支持撤销与每月计划任务 - 修复:重开软件后目录整理页现在回填已保存配置,并以任务计划程序实际状态为准显示开关;撤销按 profile 过滤,桌面与下载互不干扰 - 性能:激活状态查询移出启动流程,仅在 KMS 页/相关操作/手动刷新时按需执行;Defender 状态后台异步刷新;新增状态缓存供下次启动首显 - 去除 D:\desktop 硬编码默认值;Verification 增加下载整理、同日合并、排除规则与撤销过滤测试
This commit is contained in:
@@ -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\ 透明快捷方式图标
|
||||
|
||||
@@ -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`,可以手动维护。激活顺序为:
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user