feat: 搭建工具箱核心服务层
新增工具、分类、组合、自动运行和日志等基础模型。 实现本地 JSON 配置读写、默认系统工具恢复、路径校验、统一启动服务、自动运行服务、托盘服务、开机自启服务和全局快捷键注册服务,为后续界面集成提供稳定边界。
This commit is contained in:
@@ -7,7 +7,7 @@ namespace ToolboxApp;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interaction logic for App.xaml
|
/// Interaction logic for App.xaml
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : System.Windows.Application
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
src/ToolboxApp/Commands/RelayCommand.cs
Normal file
82
src/ToolboxApp/Commands/RelayCommand.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Commands;
|
||||||
|
|
||||||
|
public sealed class RelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<object?> _execute;
|
||||||
|
private readonly Predicate<object?>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||||
|
: this(_ => execute(), canExecute is null ? null : _ => canExecute())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter)
|
||||||
|
{
|
||||||
|
return _canExecute?.Invoke(parameter) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
_execute(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AsyncRelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Func<Task> _execute;
|
||||||
|
private readonly Func<bool>? _canExecute;
|
||||||
|
private bool _isRunning;
|
||||||
|
|
||||||
|
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter)
|
||||||
|
{
|
||||||
|
return !_isRunning && (_canExecute?.Invoke() ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
if (!CanExecute(parameter))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isRunning = true;
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
await _execute();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isRunning = false;
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/ToolboxApp/Models/ToolModels.cs
Normal file
234
src/ToolboxApp/Models/ToolModels.cs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Models;
|
||||||
|
|
||||||
|
public enum ToolType
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
Local,
|
||||||
|
Url,
|
||||||
|
Combination
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FailurePolicy
|
||||||
|
{
|
||||||
|
Continue,
|
||||||
|
Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LaunchResultKind
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
PathMissing,
|
||||||
|
InvalidUrl,
|
||||||
|
AccessDenied,
|
||||||
|
UserCancelledElevation,
|
||||||
|
ProcessStartFailed,
|
||||||
|
CircularReference,
|
||||||
|
DuplicateTool,
|
||||||
|
MissingReference,
|
||||||
|
UnknownError,
|
||||||
|
Skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolItem
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public ToolType Type { get; set; }
|
||||||
|
public string CategoryId { get; set; } = "";
|
||||||
|
public string IconKey { get; set; } = "toolbox";
|
||||||
|
public string? LaunchTarget { get; set; }
|
||||||
|
public string? Url { get; set; }
|
||||||
|
public string? Arguments { get; set; }
|
||||||
|
public string? WorkingDirectory { get; set; }
|
||||||
|
public string? SystemToolKey { get; set; }
|
||||||
|
public string? Hotkey { get; set; }
|
||||||
|
public string HotkeyStatus { get; set; } = "未设置";
|
||||||
|
public bool AutoRunEnabled { get; set; }
|
||||||
|
public bool RunAsAdmin { get; set; }
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
public bool PathInvalid { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public CombinationConfig? Combination { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsCombination => Type == ToolType.Combination;
|
||||||
|
|
||||||
|
public ToolItem Clone()
|
||||||
|
{
|
||||||
|
return new ToolItem
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Name = Name,
|
||||||
|
Description = Description,
|
||||||
|
Type = Type,
|
||||||
|
CategoryId = CategoryId,
|
||||||
|
IconKey = IconKey,
|
||||||
|
LaunchTarget = LaunchTarget,
|
||||||
|
Url = Url,
|
||||||
|
Arguments = Arguments,
|
||||||
|
WorkingDirectory = WorkingDirectory,
|
||||||
|
SystemToolKey = SystemToolKey,
|
||||||
|
Hotkey = Hotkey,
|
||||||
|
HotkeyStatus = HotkeyStatus,
|
||||||
|
AutoRunEnabled = AutoRunEnabled,
|
||||||
|
RunAsAdmin = RunAsAdmin,
|
||||||
|
IsDeleted = IsDeleted,
|
||||||
|
PathInvalid = PathInvalid,
|
||||||
|
SortOrder = SortOrder,
|
||||||
|
Combination = Combination?.Clone()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CombinationConfig
|
||||||
|
{
|
||||||
|
public FailurePolicy FailurePolicy { get; set; } = FailurePolicy.Continue;
|
||||||
|
public List<CombinationMember> Members { get; set; } = [];
|
||||||
|
|
||||||
|
public CombinationConfig Clone()
|
||||||
|
{
|
||||||
|
return new CombinationConfig
|
||||||
|
{
|
||||||
|
FailurePolicy = FailurePolicy,
|
||||||
|
Members = Members.Select(member => member.Clone()).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CombinationMember
|
||||||
|
{
|
||||||
|
public string ToolId { get; set; } = "";
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public int IntervalAfterMs { get; set; }
|
||||||
|
|
||||||
|
public CombinationMember Clone()
|
||||||
|
{
|
||||||
|
return new CombinationMember
|
||||||
|
{
|
||||||
|
ToolId = ToolId,
|
||||||
|
Enabled = Enabled,
|
||||||
|
SortOrder = SortOrder,
|
||||||
|
IntervalAfterMs = IntervalAfterMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CategoryItem
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string IconKey { get; set; } = "category";
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsBuiltIn { get; set; }
|
||||||
|
|
||||||
|
public CategoryItem Clone()
|
||||||
|
{
|
||||||
|
return new CategoryItem
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Name = Name,
|
||||||
|
IconKey = IconKey,
|
||||||
|
SortOrder = SortOrder,
|
||||||
|
IsBuiltIn = IsBuiltIn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AutoRunEntry
|
||||||
|
{
|
||||||
|
public string ToolId { get; set; } = "";
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public int IntervalAfterMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppSettings
|
||||||
|
{
|
||||||
|
public int DataVersion { get; set; } = 1;
|
||||||
|
public bool StartHiddenToTray { get; set; }
|
||||||
|
public bool HideOnClose { get; set; } = true;
|
||||||
|
public bool ConfirmExit { get; set; } = true;
|
||||||
|
public bool GlobalHotkeysEnabled { get; set; } = true;
|
||||||
|
public bool LogPanelExpanded { get; set; } = true;
|
||||||
|
public string Theme { get; set; } = "FollowSystem";
|
||||||
|
public string CardSize { get; set; } = "Medium";
|
||||||
|
public double MainWindowWidth { get; set; } = 1120;
|
||||||
|
public double MainWindowHeight { get; set; } = 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolboxData
|
||||||
|
{
|
||||||
|
public AppSettings Settings { get; set; } = new();
|
||||||
|
public List<CategoryItem> Categories { get; set; } = [];
|
||||||
|
public List<ToolItem> Tools { get; set; } = [];
|
||||||
|
public List<AutoRunEntry> AutoRunEntries { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DataEnvelope<T>
|
||||||
|
{
|
||||||
|
public int DataVersion { get; set; } = 1;
|
||||||
|
public List<T> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LaunchResult
|
||||||
|
{
|
||||||
|
public string ToolId { get; set; } = "";
|
||||||
|
public string ToolName { get; set; } = "";
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public LaunchResultKind Kind { get; set; }
|
||||||
|
|
||||||
|
public static LaunchResult Ok(ToolItem tool)
|
||||||
|
{
|
||||||
|
return new LaunchResult
|
||||||
|
{
|
||||||
|
ToolId = tool.Id,
|
||||||
|
ToolName = tool.Name,
|
||||||
|
Success = true,
|
||||||
|
Kind = LaunchResultKind.Success
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LaunchResult Fail(ToolItem tool, LaunchResultKind kind, string message)
|
||||||
|
{
|
||||||
|
return new LaunchResult
|
||||||
|
{
|
||||||
|
ToolId = tool.Id,
|
||||||
|
ToolName = tool.Name,
|
||||||
|
Success = false,
|
||||||
|
Kind = kind,
|
||||||
|
ErrorMessage = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LogMessage
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.Now;
|
||||||
|
public LogLevel Level { get; init; }
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string LevelText => Level switch
|
||||||
|
{
|
||||||
|
LogLevel.Success => "Success",
|
||||||
|
LogLevel.Warning => "Warning",
|
||||||
|
LogLevel.Error => "Error",
|
||||||
|
_ => "Info"
|
||||||
|
};
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string DisplayText => $"[{Timestamp:HH:mm:ss}] [{LevelText}] {Message}";
|
||||||
|
}
|
||||||
69
src/ToolboxApp/Services/AutoRunService.cs
Normal file
69
src/ToolboxApp/Services/AutoRunService.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class AutoRunService
|
||||||
|
{
|
||||||
|
private readonly ToolLaunchService _launchService;
|
||||||
|
|
||||||
|
public AutoRunService(ToolLaunchService launchService)
|
||||||
|
{
|
||||||
|
_launchService = launchService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(ToolboxData data, Action<LogMessage> log, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entries = data.AutoRunEntries
|
||||||
|
.Where(entry => entry.Enabled)
|
||||||
|
.OrderBy(entry => entry.SortOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(CreateLog(LogLevel.Info, "开始执行启动时自动运行"));
|
||||||
|
var success = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var tool = data.Tools.FirstOrDefault(item => item.Id == entry.ToolId && item.AutoRunEnabled && !item.IsDeleted);
|
||||||
|
if (tool is null)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
log(CreateLog(LogLevel.Warning, $"自动运行项不存在或已禁用:{entry.ToolId}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _launchService.LaunchAsync(tool, data.Tools, log, cancellationToken);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
success++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.IntervalAfterMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(entry.IntervalAfterMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(CreateLog(LogLevel.Info, $"启动时自动运行完成:成功 {success} 项,失败 {failed} 项"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogMessage CreateLog(LogLevel level, string message)
|
||||||
|
{
|
||||||
|
return new LogMessage
|
||||||
|
{
|
||||||
|
Level = level,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/ToolboxApp/Services/ConfigurationService.cs
Normal file
264
src/ToolboxApp/Services/ConfigurationService.cs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class ConfigurationService
|
||||||
|
{
|
||||||
|
private const int CurrentDataVersion = 1;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public string ConfigDirectory { get; }
|
||||||
|
public string IconsDirectory => Path.Combine(ConfigDirectory, "icons");
|
||||||
|
|
||||||
|
private string SettingsPath => Path.Combine(ConfigDirectory, "appsettings.json");
|
||||||
|
private string CategoriesPath => Path.Combine(ConfigDirectory, "categories.json");
|
||||||
|
private string ToolsPath => Path.Combine(ConfigDirectory, "tools.json");
|
||||||
|
private string AutoRunPath => Path.Combine(ConfigDirectory, "autorun.json");
|
||||||
|
|
||||||
|
public ConfigurationService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
ConfigDirectory = Path.Combine(appData, "ToolboxApp");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ToolboxData> LoadAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureDirectories();
|
||||||
|
|
||||||
|
var data = new ToolboxData
|
||||||
|
{
|
||||||
|
Settings = await ReadFileAsync<AppSettings>(SettingsPath, cancellationToken) ?? new AppSettings(),
|
||||||
|
Categories = (await ReadEnvelopeAsync<CategoryItem>(CategoriesPath, cancellationToken)).Items,
|
||||||
|
Tools = (await ReadEnvelopeAsync<ToolItem>(ToolsPath, cancellationToken)).Items,
|
||||||
|
AutoRunEntries = (await ReadEnvelopeAsync<AutoRunEntry>(AutoRunPath, cancellationToken)).Items
|
||||||
|
};
|
||||||
|
|
||||||
|
data.Settings.DataVersion = CurrentDataVersion;
|
||||||
|
SystemToolService.EnsureDefaultCategories(data.Categories);
|
||||||
|
SystemToolService.RestoreDefaultSystemTools(data.Categories, data.Tools);
|
||||||
|
MergeAutoRunEntries(data);
|
||||||
|
|
||||||
|
await SaveAsync(data, cancellationToken);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(ToolboxData data, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureDirectories();
|
||||||
|
data.Settings.DataVersion = CurrentDataVersion;
|
||||||
|
|
||||||
|
await WriteFileAtomicAsync(SettingsPath, data.Settings, cancellationToken);
|
||||||
|
await WriteFileAtomicAsync(CategoriesPath, new DataEnvelope<CategoryItem>
|
||||||
|
{
|
||||||
|
DataVersion = CurrentDataVersion,
|
||||||
|
Items = data.Categories.OrderBy(category => category.SortOrder).ToList()
|
||||||
|
}, cancellationToken);
|
||||||
|
await WriteFileAtomicAsync(ToolsPath, new DataEnvelope<ToolItem>
|
||||||
|
{
|
||||||
|
DataVersion = CurrentDataVersion,
|
||||||
|
Items = data.Tools.OrderBy(tool => tool.SortOrder).ToList()
|
||||||
|
}, cancellationToken);
|
||||||
|
await WriteFileAtomicAsync(AutoRunPath, new DataEnvelope<AutoRunEntry>
|
||||||
|
{
|
||||||
|
DataVersion = CurrentDataVersion,
|
||||||
|
Items = data.AutoRunEntries.OrderBy(entry => entry.SortOrder).ToList()
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ToolboxData> ResetAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
BackupCurrentConfig();
|
||||||
|
|
||||||
|
DeleteIfExists(SettingsPath);
|
||||||
|
DeleteIfExists(CategoriesPath);
|
||||||
|
DeleteIfExists(ToolsPath);
|
||||||
|
DeleteIfExists(AutoRunPath);
|
||||||
|
|
||||||
|
return await LoadAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExportAsync(string zipPath, ToolboxData data, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await SaveAsync(data, cancellationToken);
|
||||||
|
|
||||||
|
if (File.Exists(zipPath))
|
||||||
|
{
|
||||||
|
File.Delete(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ZipFile.CreateFromDirectory(ConfigDirectory, zipPath, CompressionLevel.Optimal, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ToolboxData> ImportAsync(string sourcePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
BackupCurrentConfig();
|
||||||
|
|
||||||
|
var importDirectory = sourcePath;
|
||||||
|
var tempDirectory = "";
|
||||||
|
if (File.Exists(sourcePath) && Path.GetExtension(sourcePath).Equals(".zip", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
tempDirectory = Path.Combine(Path.GetTempPath(), "ToolboxAppImport_" + Guid.NewGuid().ToString("N"));
|
||||||
|
ZipFile.ExtractToDirectory(sourcePath, tempDirectory);
|
||||||
|
importDirectory = tempDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CopyIfExists(Path.Combine(importDirectory, "appsettings.json"), SettingsPath);
|
||||||
|
CopyIfExists(Path.Combine(importDirectory, "categories.json"), CategoriesPath);
|
||||||
|
CopyIfExists(Path.Combine(importDirectory, "tools.json"), ToolsPath);
|
||||||
|
CopyIfExists(Path.Combine(importDirectory, "autorun.json"), AutoRunPath);
|
||||||
|
|
||||||
|
var importIcons = Path.Combine(importDirectory, "icons");
|
||||||
|
if (Directory.Exists(importIcons))
|
||||||
|
{
|
||||||
|
CopyDirectory(importIcons, IconsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await LoadAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tempDirectory) && Directory.Exists(tempDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(tempDirectory, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenConfigDirectory()
|
||||||
|
{
|
||||||
|
EnsureDirectories();
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ConfigDirectory,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MergeAutoRunEntries(ToolboxData data)
|
||||||
|
{
|
||||||
|
var existingIds = data.AutoRunEntries.Select(entry => entry.ToolId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var nextOrder = data.AutoRunEntries.Count == 0 ? 0 : data.AutoRunEntries.Max(entry => entry.SortOrder) + 1;
|
||||||
|
|
||||||
|
foreach (var tool in data.Tools.Where(tool => tool.AutoRunEnabled && !tool.IsDeleted))
|
||||||
|
{
|
||||||
|
if (existingIds.Contains(tool.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.AutoRunEntries.Add(new AutoRunEntry
|
||||||
|
{
|
||||||
|
ToolId = tool.Id,
|
||||||
|
Enabled = true,
|
||||||
|
SortOrder = nextOrder++,
|
||||||
|
IntervalAfterMs = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabledToolIds = data.Tools.Where(tool => tool.AutoRunEnabled && !tool.IsDeleted)
|
||||||
|
.Select(tool => tool.Id)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
data.AutoRunEntries.RemoveAll(entry => !enabledToolIds.Contains(entry.ToolId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> ReadFileAsync<T>(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
return await JsonSerializer.DeserializeAsync<T>(stream, _jsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DataEnvelope<T>> ReadEnvelopeAsync<T>(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ReadFileAsync<DataEnvelope<T>>(path, cancellationToken) ?? new DataEnvelope<T> { DataVersion = CurrentDataVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteFileAtomicAsync<T>(string path, T value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
await using (var stream = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, value, _jsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDirectories()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ConfigDirectory);
|
||||||
|
Directory.CreateDirectory(IconsDirectory);
|
||||||
|
Directory.CreateDirectory(Path.Combine(IconsDirectory, "cache"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(IconsDirectory, "custom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackupCurrentConfig()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ConfigDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupRoot = Path.Combine(ConfigDirectory, "backups");
|
||||||
|
Directory.CreateDirectory(backupRoot);
|
||||||
|
var backupDirectory = Path.Combine(backupRoot, DateTime.Now.ToString("yyyyMMdd_HHmmss"));
|
||||||
|
Directory.CreateDirectory(backupDirectory);
|
||||||
|
|
||||||
|
foreach (var file in new[] { SettingsPath, CategoriesPath, ToolsPath, AutoRunPath })
|
||||||
|
{
|
||||||
|
if (File.Exists(file))
|
||||||
|
{
|
||||||
|
File.Copy(file, Path.Combine(backupDirectory, Path.GetFileName(file)), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteIfExists(string path)
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyIfExists(string source, string target)
|
||||||
|
{
|
||||||
|
if (File.Exists(source))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||||
|
File.Copy(source, target, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyDirectory(string source, string target)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(target);
|
||||||
|
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory.Replace(source, target, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var targetPath = file.Replace(source, target, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
File.Copy(file, targetPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
285
src/ToolboxApp/Services/HotkeyService.cs
Normal file
285
src/ToolboxApp/Services/HotkeyService.cs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class HotkeyService : IDisposable
|
||||||
|
{
|
||||||
|
private const int WmHotkey = 0x0312;
|
||||||
|
private readonly Dictionary<int, string> _registeredHotkeys = new();
|
||||||
|
private HwndSource? _source;
|
||||||
|
private Func<string, Task>? _onTriggered;
|
||||||
|
private Action<LogMessage>? _log;
|
||||||
|
private int _nextId = 1000;
|
||||||
|
private nint _handle;
|
||||||
|
|
||||||
|
public void RegisterAll(
|
||||||
|
Window window,
|
||||||
|
IEnumerable<ToolItem> tools,
|
||||||
|
bool enabled,
|
||||||
|
Func<string, Task> onTriggered,
|
||||||
|
Action<LogMessage> log)
|
||||||
|
{
|
||||||
|
UnregisterAll();
|
||||||
|
|
||||||
|
_onTriggered = onTriggered;
|
||||||
|
_log = log;
|
||||||
|
|
||||||
|
var helper = new WindowInteropHelper(window);
|
||||||
|
_handle = helper.Handle;
|
||||||
|
if (_handle == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_source ??= HwndSource.FromHwnd(_handle);
|
||||||
|
_source?.RemoveHook(WndProc);
|
||||||
|
_source?.AddHook(WndProc);
|
||||||
|
|
||||||
|
foreach (var tool in tools)
|
||||||
|
{
|
||||||
|
tool.HotkeyStatus = string.IsNullOrWhiteSpace(tool.Hotkey) ? "未设置" : "等待注册";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
foreach (var tool in tools.Where(tool => !string.IsNullOrWhiteSpace(tool.Hotkey)))
|
||||||
|
{
|
||||||
|
tool.HotkeyStatus = "已禁用";
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTools = tools
|
||||||
|
.Where(tool => !tool.IsDeleted && !string.IsNullOrWhiteSpace(tool.Hotkey))
|
||||||
|
.ToList();
|
||||||
|
var conflicts = activeTools
|
||||||
|
.GroupBy(tool => HotkeyParser.Normalize(tool.Hotkey!))
|
||||||
|
.Where(group => group.Count() > 1)
|
||||||
|
.SelectMany(group => group)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var conflict in conflicts)
|
||||||
|
{
|
||||||
|
conflict.HotkeyStatus = "内部冲突";
|
||||||
|
log(CreateLog(LogLevel.Warning, $"快捷键内部冲突:{conflict.Hotkey},{conflict.Name}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tool in activeTools.Where(tool => !conflicts.Contains(tool)))
|
||||||
|
{
|
||||||
|
if (!HotkeyParser.TryParse(tool.Hotkey, out var modifiers, out var key))
|
||||||
|
{
|
||||||
|
tool.HotkeyStatus = "格式无效";
|
||||||
|
log(CreateLog(LogLevel.Warning, $"快捷键格式无效:{tool.Name},{tool.Hotkey}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = _nextId++;
|
||||||
|
if (RegisterHotKey(_handle, id, modifiers, key))
|
||||||
|
{
|
||||||
|
_registeredHotkeys[id] = tool.Id;
|
||||||
|
tool.HotkeyStatus = "正常";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tool.HotkeyStatus = "系统注册失败";
|
||||||
|
log(CreateLog(LogLevel.Warning, $"快捷键注册失败:{tool.Name},{tool.Hotkey}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterAll()
|
||||||
|
{
|
||||||
|
if (_handle != 0)
|
||||||
|
{
|
||||||
|
foreach (var id in _registeredHotkeys.Keys.ToList())
|
||||||
|
{
|
||||||
|
UnregisterHotKey(_handle, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_registeredHotkeys.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UnregisterAll();
|
||||||
|
_source?.RemoveHook(WndProc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
|
||||||
|
{
|
||||||
|
if (msg == WmHotkey && _registeredHotkeys.TryGetValue(wParam.ToInt32(), out var toolId))
|
||||||
|
{
|
||||||
|
handled = true;
|
||||||
|
_ = TriggerAsync(toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TriggerAsync(string toolId)
|
||||||
|
{
|
||||||
|
if (_onTriggered is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _onTriggered(toolId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log?.Invoke(CreateLog(LogLevel.Error, $"快捷键触发失败:{ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogMessage CreateLog(LogLevel level, string message)
|
||||||
|
{
|
||||||
|
return new LogMessage
|
||||||
|
{
|
||||||
|
Level = level,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool RegisterHotKey(nint hWnd, int id, uint fsModifiers, uint vk);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool UnregisterHotKey(nint hWnd, int id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HotkeyParser
|
||||||
|
{
|
||||||
|
private const uint ModAlt = 0x0001;
|
||||||
|
private const uint ModControl = 0x0002;
|
||||||
|
private const uint ModShift = 0x0004;
|
||||||
|
private const uint ModWin = 0x0008;
|
||||||
|
|
||||||
|
public static string Normalize(string hotkey)
|
||||||
|
{
|
||||||
|
return string.Join("+", hotkey.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(NormalizePart)
|
||||||
|
.Where(part => part.Length > 0)
|
||||||
|
.OrderBy(GetPartOrder)
|
||||||
|
.ThenBy(part => part)
|
||||||
|
.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParse(string? hotkey, out uint modifiers, out uint key)
|
||||||
|
{
|
||||||
|
modifiers = 0;
|
||||||
|
key = 0;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(hotkey))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyPart = "";
|
||||||
|
foreach (var rawPart in hotkey.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var part = NormalizePart(rawPart);
|
||||||
|
switch (part)
|
||||||
|
{
|
||||||
|
case "Ctrl":
|
||||||
|
modifiers |= ModControl;
|
||||||
|
break;
|
||||||
|
case "Alt":
|
||||||
|
modifiers |= ModAlt;
|
||||||
|
break;
|
||||||
|
case "Shift":
|
||||||
|
modifiers |= ModShift;
|
||||||
|
break;
|
||||||
|
case "Win":
|
||||||
|
modifiers |= ModWin;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
keyPart = part;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiers == 0 || string.IsNullOrWhiteSpace(keyPart))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
key = KeyToVirtualKey(keyPart);
|
||||||
|
return key != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePart(string value)
|
||||||
|
{
|
||||||
|
var part = value.Trim();
|
||||||
|
return part.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"control" or "ctrl" => "Ctrl",
|
||||||
|
"menu" or "alt" => "Alt",
|
||||||
|
"shift" => "Shift",
|
||||||
|
"windows" or "win" => "Win",
|
||||||
|
_ => part.Length == 1 ? part.ToUpperInvariant() : part.ToUpperInvariant().StartsWith("F", StringComparison.Ordinal) ? part.ToUpperInvariant() : part
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetPartOrder(string part)
|
||||||
|
{
|
||||||
|
return part switch
|
||||||
|
{
|
||||||
|
"Ctrl" => 0,
|
||||||
|
"Alt" => 1,
|
||||||
|
"Shift" => 2,
|
||||||
|
"Win" => 3,
|
||||||
|
_ => 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint KeyToVirtualKey(string keyPart)
|
||||||
|
{
|
||||||
|
if (keyPart.Length == 1)
|
||||||
|
{
|
||||||
|
var character = keyPart[0];
|
||||||
|
if (character is >= 'A' and <= 'Z')
|
||||||
|
{
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character is >= '0' and <= '9')
|
||||||
|
{
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyPart.StartsWith("F", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& int.TryParse(keyPart[1..], out var functionKey)
|
||||||
|
&& functionKey is >= 1 and <= 24)
|
||||||
|
{
|
||||||
|
return (uint)(0x70 + functionKey - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyPart.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"escape" or "esc" => 0x1B,
|
||||||
|
"tab" => 0x09,
|
||||||
|
"space" => 0x20,
|
||||||
|
"enter" => 0x0D,
|
||||||
|
"backspace" => 0x08,
|
||||||
|
"delete" => 0x2E,
|
||||||
|
"insert" => 0x2D,
|
||||||
|
"home" => 0x24,
|
||||||
|
"end" => 0x23,
|
||||||
|
"pageup" => 0x21,
|
||||||
|
"pagedown" => 0x22,
|
||||||
|
"left" => 0x25,
|
||||||
|
"up" => 0x26,
|
||||||
|
"right" => 0x27,
|
||||||
|
"down" => 0x28,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/ToolboxApp/Services/PathValidationService.cs
Normal file
69
src/ToolboxApp/Services/PathValidationService.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using System.IO;
|
||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class PathValidationService
|
||||||
|
{
|
||||||
|
public int ValidateTools(IEnumerable<ToolItem> tools)
|
||||||
|
{
|
||||||
|
var invalidCount = 0;
|
||||||
|
foreach (var tool in tools)
|
||||||
|
{
|
||||||
|
tool.PathInvalid = false;
|
||||||
|
|
||||||
|
if (tool.IsDeleted || tool.Type != ToolType.Local)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tool.LaunchTarget) || !PathExists(tool.LaunchTarget))
|
||||||
|
{
|
||||||
|
tool.PathInvalid = true;
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tool.WorkingDirectory) && !Directory.Exists(tool.WorkingDirectory))
|
||||||
|
{
|
||||||
|
tool.PathInvalid = true;
|
||||||
|
invalidCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalidCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValidUrl(string? rawUrl, out string normalizedUrl)
|
||||||
|
{
|
||||||
|
normalizedUrl = "";
|
||||||
|
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = rawUrl.Trim();
|
||||||
|
if (!value.Contains("://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
value = "https://" + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedUrl = uri.ToString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PathExists(string path)
|
||||||
|
{
|
||||||
|
return File.Exists(path) || Directory.Exists(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/ToolboxApp/Services/StartupService.cs
Normal file
31
src/ToolboxApp/Services/StartupService.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class StartupService
|
||||||
|
{
|
||||||
|
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
|
private const string RunValueName = "ToolboxApp";
|
||||||
|
|
||||||
|
public bool IsEnabled()
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, false);
|
||||||
|
return key?.GetValue(RunValueName) is string value && !string.IsNullOrWhiteSpace(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, true)
|
||||||
|
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, true);
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
var exePath = Environment.ProcessPath ?? "";
|
||||||
|
key.SetValue(RunValueName, $"\"{exePath}\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.DeleteValue(RunValueName, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/ToolboxApp/Services/SystemToolService.cs
Normal file
101
src/ToolboxApp/Services/SystemToolService.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public static class SystemToolService
|
||||||
|
{
|
||||||
|
public const string SystemCategoryId = "category-system-tools";
|
||||||
|
public const string UncategorizedCategoryId = "category-uncategorized";
|
||||||
|
|
||||||
|
public static void EnsureDefaultCategories(ICollection<CategoryItem> categories)
|
||||||
|
{
|
||||||
|
if (categories.All(category => category.Id != SystemCategoryId))
|
||||||
|
{
|
||||||
|
categories.Add(new CategoryItem
|
||||||
|
{
|
||||||
|
Id = SystemCategoryId,
|
||||||
|
Name = "系统工具",
|
||||||
|
IconKey = "system",
|
||||||
|
SortOrder = 0,
|
||||||
|
IsBuiltIn = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.All(category => category.Id != UncategorizedCategoryId))
|
||||||
|
{
|
||||||
|
categories.Add(new CategoryItem
|
||||||
|
{
|
||||||
|
Id = UncategorizedCategoryId,
|
||||||
|
Name = "未分类",
|
||||||
|
IconKey = "category",
|
||||||
|
SortOrder = 999,
|
||||||
|
IsBuiltIn = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int RestoreDefaultSystemTools(ICollection<CategoryItem> categories, ICollection<ToolItem> tools)
|
||||||
|
{
|
||||||
|
EnsureDefaultCategories(categories);
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
foreach (var definition in GetDefaultDefinitions())
|
||||||
|
{
|
||||||
|
var exists = tools.Any(tool => tool.Type == ToolType.System && tool.SystemToolKey == definition.SystemToolKey && !tool.IsDeleted);
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.Add(definition);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReorderTools(tools);
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ToolItem> GetDefaultDefinitions()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
Create("system-notepad", "记事本", "快速打开 Windows 记事本。", "notepad.exe", "notepad", 0),
|
||||||
|
Create("system-calculator", "计算器", "快速打开 Windows 计算器。", "calc.exe", "calculator", 1),
|
||||||
|
Create("system-task-manager", "任务管理器", "查看和管理正在运行的应用与进程。", "taskmgr.exe", "taskmgr", 2),
|
||||||
|
Create("system-control-panel", "控制面板", "打开传统控制面板。", "control", "control", 3),
|
||||||
|
Create("system-settings", "系统设置", "打开 Windows 设置主页。", "ms-settings:", "settings", 4),
|
||||||
|
Create("system-windows-update", "Windows 更新", "打开 Windows 更新设置页。", "ms-settings:windowsupdate", "settings", 5),
|
||||||
|
Create("system-device-manager", "设备管理器", "查看和管理硬件设备。", "devmgmt.msc", "device", 6),
|
||||||
|
Create("system-disk-management", "磁盘管理", "打开磁盘和分区管理工具。", "diskmgmt.msc", "disk", 7),
|
||||||
|
Create("system-services", "服务", "查看和管理 Windows 服务。", "services.msc", "service", 8),
|
||||||
|
Create("system-registry-editor", "注册表编辑器", "打开注册表编辑器,需要谨慎使用。", "regedit.exe", "registry", 9),
|
||||||
|
Create("system-network-connections", "网络连接", "打开网络适配器列表。", "ncpa.cpl", "network", 10),
|
||||||
|
Create("system-apps-folder", "应用列表", "打开 shell:AppsFolder 以查看 Store 应用和快捷方式。", "shell:AppsFolder", "apps", 11)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToolItem Create(string key, string name, string description, string target, string iconKey, int order)
|
||||||
|
{
|
||||||
|
return new ToolItem
|
||||||
|
{
|
||||||
|
Id = key,
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
Type = ToolType.System,
|
||||||
|
CategoryId = SystemCategoryId,
|
||||||
|
IconKey = iconKey,
|
||||||
|
LaunchTarget = target,
|
||||||
|
SystemToolKey = key,
|
||||||
|
SortOrder = order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReorderTools(IEnumerable<ToolItem> tools)
|
||||||
|
{
|
||||||
|
var order = 0;
|
||||||
|
foreach (var tool in tools.OrderBy(tool => tool.CategoryId).ThenBy(tool => tool.SortOrder).ThenBy(tool => tool.Name))
|
||||||
|
{
|
||||||
|
tool.SortOrder = order++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/ToolboxApp/Services/ToolLaunchService.cs
Normal file
251
src/ToolboxApp/Services/ToolLaunchService.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using ToolboxApp.Models;
|
||||||
|
|
||||||
|
namespace ToolboxApp.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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/ToolboxApp/Services/TrayService.cs
Normal file
89
src/ToolboxApp/Services/TrayService.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows;
|
||||||
|
using Forms = System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ToolboxApp.Services;
|
||||||
|
|
||||||
|
public sealed class TrayService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Window _window;
|
||||||
|
private readonly Action _openSettings;
|
||||||
|
private readonly Action<bool> _setHotkeysEnabled;
|
||||||
|
private readonly Func<bool> _getHotkeysEnabled;
|
||||||
|
private readonly Action _requestExit;
|
||||||
|
private readonly Forms.NotifyIcon _notifyIcon;
|
||||||
|
private readonly Forms.ToolStripMenuItem _toggleWindowItem;
|
||||||
|
private readonly Forms.ToolStripMenuItem _toggleHotkeysItem;
|
||||||
|
|
||||||
|
public TrayService(
|
||||||
|
Window window,
|
||||||
|
Action openSettings,
|
||||||
|
Action<bool> setHotkeysEnabled,
|
||||||
|
Func<bool> getHotkeysEnabled,
|
||||||
|
Action requestExit)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
_openSettings = openSettings;
|
||||||
|
_setHotkeysEnabled = setHotkeysEnabled;
|
||||||
|
_getHotkeysEnabled = getHotkeysEnabled;
|
||||||
|
_requestExit = requestExit;
|
||||||
|
|
||||||
|
_toggleWindowItem = new Forms.ToolStripMenuItem("显示 / 隐藏主界面", null, (_, _) => ToggleWindow());
|
||||||
|
_toggleHotkeysItem = new Forms.ToolStripMenuItem("全局快捷键:开启", null, (_, _) => ToggleHotkeys());
|
||||||
|
var settingsItem = new Forms.ToolStripMenuItem("设置", null, (_, _) => _openSettings());
|
||||||
|
var exitItem = new Forms.ToolStripMenuItem("退出", null, (_, _) => _requestExit());
|
||||||
|
|
||||||
|
_notifyIcon = new Forms.NotifyIcon
|
||||||
|
{
|
||||||
|
Icon = SystemIcons.Application,
|
||||||
|
Text = "个人工具箱",
|
||||||
|
Visible = true,
|
||||||
|
ContextMenuStrip = new Forms.ContextMenuStrip()
|
||||||
|
};
|
||||||
|
_notifyIcon.ContextMenuStrip.Items.AddRange([_toggleWindowItem, _toggleHotkeysItem, settingsItem, exitItem]);
|
||||||
|
_notifyIcon.MouseClick += NotifyIconOnMouseClick;
|
||||||
|
RefreshMenuText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshMenuText()
|
||||||
|
{
|
||||||
|
_toggleWindowItem.Text = _window.IsVisible ? "隐藏主界面" : "显示主界面";
|
||||||
|
_toggleHotkeysItem.Text = _getHotkeysEnabled() ? "全局快捷键:关闭" : "全局快捷键:开启";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleWindow()
|
||||||
|
{
|
||||||
|
if (_window.IsVisible)
|
||||||
|
{
|
||||||
|
_window.Hide();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_window.Show();
|
||||||
|
_window.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshMenuText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_notifyIcon.Visible = false;
|
||||||
|
_notifyIcon.MouseClick -= NotifyIconOnMouseClick;
|
||||||
|
_notifyIcon.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleHotkeys()
|
||||||
|
{
|
||||||
|
_setHotkeysEnabled(!_getHotkeysEnabled());
|
||||||
|
RefreshMenuText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyIconOnMouseClick(object? sender, Forms.MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button == Forms.MouseButtons.Left)
|
||||||
|
{
|
||||||
|
ToggleWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
26
src/ToolboxApp/ViewModels/ObservableObject.cs
Normal file
26
src/ToolboxApp/ViewModels/ObservableObject.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace ToolboxApp.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ObservableObject : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(storage, value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
storage = value;
|
||||||
|
OnPropertyChanged(propertyName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user