feat: 统一项目命名并补充路径失效报告

将内部项目目录、命名空间、配置目录、自启注册表值和设计/开发文档统一为 PersonalToolbox。

扩展路径校验服务,输出失效工具、字段、原因和路径,并在启动日志、设置页路径检查与导入配置流程中展示明细报告。

验证:dotnet build PersonalToolbox.sln
This commit is contained in:
2026-05-27 14:20:19 +08:00
parent dfc306818a
commit 26a22eef1c
32 changed files with 236 additions and 150 deletions

View File

@@ -0,0 +1,251 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using PersonalToolbox.Models;
namespace PersonalToolbox.Services;
public sealed class ToolLaunchService
{
public async Task<LaunchResult> LaunchAsync(
ToolItem tool,
IReadOnlyCollection<ToolItem> allTools,
Action<LogMessage> log,
CancellationToken cancellationToken = default)
{
if (tool.IsDeleted)
{
var result = LaunchResult.Fail(tool, LaunchResultKind.Skipped, "工具已删除。");
log(CreateLog(LogLevel.Warning, $"跳过已删除工具:{tool.Name}"));
return result;
}
if (tool.Type == ToolType.Combination)
{
return await LaunchCombinationAsync(tool, allTools, log, cancellationToken);
}
return LaunchSingleTool(tool, log);
}
private async Task<LaunchResult> LaunchCombinationAsync(
ToolItem combination,
IReadOnlyCollection<ToolItem> allTools,
Action<LogMessage> log,
CancellationToken cancellationToken)
{
var validation = ValidateCombination(combination, allTools);
if (!validation.Success)
{
log(CreateLog(LogLevel.Error, $"组合校验失败:{combination.Name}{validation.ErrorMessage}"));
return validation;
}
log(CreateLog(LogLevel.Info, $"启动组合:{combination.Name}"));
var successCount = 0;
var failedCount = 0;
var members = combination.Combination?.Members
.Where(member => member.Enabled)
.OrderBy(member => member.SortOrder)
.ToList() ?? [];
foreach (var member in members)
{
cancellationToken.ThrowIfCancellationRequested();
var memberTool = allTools.FirstOrDefault(tool => tool.Id == member.ToolId && !tool.IsDeleted);
if (memberTool is null)
{
failedCount++;
log(CreateLog(LogLevel.Error, $"组合成员不存在:{member.ToolId}"));
if (combination.Combination?.FailurePolicy == FailurePolicy.Stop)
{
break;
}
continue;
}
var result = await LaunchAsync(memberTool, allTools, log, cancellationToken);
if (result.Success)
{
successCount++;
}
else
{
failedCount++;
if (combination.Combination?.FailurePolicy == FailurePolicy.Stop)
{
log(CreateLog(LogLevel.Warning, $"组合已停止:{combination.Name}"));
break;
}
}
if (member.IntervalAfterMs > 0)
{
await Task.Delay(member.IntervalAfterMs, cancellationToken);
}
}
log(CreateLog(LogLevel.Info, $"组合执行完成:{combination.Name},成功 {successCount} 项,失败 {failedCount} 项"));
return failedCount == 0
? LaunchResult.Ok(combination)
: LaunchResult.Fail(combination, LaunchResultKind.UnknownError, $"组合执行完成,但有 {failedCount} 项失败。");
}
public LaunchResult ValidateCombination(ToolItem combination, IReadOnlyCollection<ToolItem> allTools)
{
if (combination.Type != ToolType.Combination)
{
return LaunchResult.Ok(combination);
}
var stack = new Stack<string>();
var finalToolIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var toolMap = allTools.Where(tool => !tool.IsDeleted).ToDictionary(tool => tool.Id, StringComparer.OrdinalIgnoreCase);
LaunchResult? failure = null;
void Visit(ToolItem current)
{
if (failure is not null)
{
return;
}
if (current.Type != ToolType.Combination)
{
if (!finalToolIds.Add(current.Id))
{
failure = LaunchResult.Fail(combination, LaunchResultKind.DuplicateTool, $"展开后存在重复工具:{current.Name}");
}
return;
}
if (stack.Contains(current.Id, StringComparer.OrdinalIgnoreCase))
{
var path = string.Join(" -> ", stack.Reverse().Concat([current.Id]).Select(id => toolMap.TryGetValue(id, out var tool) ? tool.Name : id));
failure = LaunchResult.Fail(combination, LaunchResultKind.CircularReference, $"存在循环引用:{path}");
return;
}
stack.Push(current.Id);
var members = current.Combination?.Members
.Where(member => member.Enabled)
.OrderBy(member => member.SortOrder)
?? Enumerable.Empty<CombinationMember>();
foreach (var member in members)
{
if (!toolMap.TryGetValue(member.ToolId, out var memberTool))
{
failure = LaunchResult.Fail(combination, LaunchResultKind.MissingReference, $"成员引用不存在:{member.ToolId}");
break;
}
Visit(memberTool);
}
stack.Pop();
}
Visit(combination);
return failure ?? LaunchResult.Ok(combination);
}
private LaunchResult LaunchSingleTool(ToolItem tool, Action<LogMessage> log)
{
try
{
var startInfo = CreateStartInfo(tool, out var validationFailure);
if (validationFailure is not null)
{
log(CreateLog(LogLevel.Error, $"启动失败:{tool.Name}{validationFailure.ErrorMessage}"));
return validationFailure;
}
log(CreateLog(LogLevel.Info, $"开始启动工具:{tool.Name}"));
Process.Start(startInfo!);
log(CreateLog(LogLevel.Success, $"启动成功:{tool.Name}"));
return LaunchResult.Ok(tool);
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
log(CreateLog(LogLevel.Warning, $"用户取消管理员权限:{tool.Name}"));
return LaunchResult.Fail(tool, LaunchResultKind.UserCancelledElevation, "用户取消管理员权限。");
}
catch (UnauthorizedAccessException ex)
{
log(CreateLog(LogLevel.Error, $"访问被拒绝:{tool.Name}{ex.Message}"));
return LaunchResult.Fail(tool, LaunchResultKind.AccessDenied, ex.Message);
}
catch (Exception ex)
{
log(CreateLog(LogLevel.Error, $"启动失败:{tool.Name}{ex.Message}"));
return LaunchResult.Fail(tool, LaunchResultKind.ProcessStartFailed, ex.Message);
}
}
private static ProcessStartInfo? CreateStartInfo(ToolItem tool, out LaunchResult? validationFailure)
{
validationFailure = null;
if (tool.Type == ToolType.Url)
{
if (!PathValidationService.IsValidUrl(tool.Url, out var normalizedUrl))
{
validationFailure = LaunchResult.Fail(tool, LaunchResultKind.InvalidUrl, "网址格式无效。");
return null;
}
return new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
}
var target = tool.LaunchTarget;
if (string.IsNullOrWhiteSpace(target))
{
validationFailure = LaunchResult.Fail(tool, LaunchResultKind.PathMissing, "启动目标为空。");
return null;
}
if (tool.Type == ToolType.Local && !File.Exists(target) && !Directory.Exists(target))
{
validationFailure = LaunchResult.Fail(tool, LaunchResultKind.PathMissing, "路径不存在。");
return null;
}
if (!string.IsNullOrWhiteSpace(tool.WorkingDirectory) && !Directory.Exists(tool.WorkingDirectory))
{
validationFailure = LaunchResult.Fail(tool, LaunchResultKind.PathMissing, "工作目录不存在。");
return null;
}
var startInfo = new ProcessStartInfo
{
FileName = target,
Arguments = tool.Arguments ?? "",
WorkingDirectory = tool.WorkingDirectory ?? "",
UseShellExecute = true
};
if (tool.RunAsAdmin)
{
startInfo.Verb = "runas";
}
return startInfo;
}
private static LogMessage CreateLog(LogLevel level, string message)
{
return new LogMessage
{
Level = level,
Message = message
};
}
}