From fde1c6bc0fef9beefce77bea8f4debab300e424b Mon Sep 17 00:00:00 2001 From: home-PC Date: Wed, 27 May 2026 13:41:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=AD=E5=BB=BA=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=AE=B1=E6=A0=B8=E5=BF=83=E6=9C=8D=E5=8A=A1=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增工具、分类、组合、自动运行和日志等基础模型。 实现本地 JSON 配置读写、默认系统工具恢复、路径校验、统一启动服务、自动运行服务、托盘服务、开机自启服务和全局快捷键注册服务,为后续界面集成提供稳定边界。 --- src/ToolboxApp/App.xaml.cs | 2 +- src/ToolboxApp/Commands/RelayCommand.cs | 82 +++++ src/ToolboxApp/Models/ToolModels.cs | 234 ++++++++++++++ src/ToolboxApp/Services/AutoRunService.cs | 69 +++++ .../Services/ConfigurationService.cs | 264 ++++++++++++++++ src/ToolboxApp/Services/HotkeyService.cs | 285 ++++++++++++++++++ .../Services/PathValidationService.cs | 69 +++++ src/ToolboxApp/Services/StartupService.cs | 31 ++ src/ToolboxApp/Services/SystemToolService.cs | 101 +++++++ src/ToolboxApp/Services/ToolLaunchService.cs | 251 +++++++++++++++ src/ToolboxApp/Services/TrayService.cs | 89 ++++++ src/ToolboxApp/ToolboxApp.csproj | 1 + src/ToolboxApp/ViewModels/ObservableObject.cs | 26 ++ 13 files changed, 1503 insertions(+), 1 deletion(-) create mode 100644 src/ToolboxApp/Commands/RelayCommand.cs create mode 100644 src/ToolboxApp/Models/ToolModels.cs create mode 100644 src/ToolboxApp/Services/AutoRunService.cs create mode 100644 src/ToolboxApp/Services/ConfigurationService.cs create mode 100644 src/ToolboxApp/Services/HotkeyService.cs create mode 100644 src/ToolboxApp/Services/PathValidationService.cs create mode 100644 src/ToolboxApp/Services/StartupService.cs create mode 100644 src/ToolboxApp/Services/SystemToolService.cs create mode 100644 src/ToolboxApp/Services/ToolLaunchService.cs create mode 100644 src/ToolboxApp/Services/TrayService.cs create mode 100644 src/ToolboxApp/ViewModels/ObservableObject.cs diff --git a/src/ToolboxApp/App.xaml.cs b/src/ToolboxApp/App.xaml.cs index ab07b05..f05315d 100644 --- a/src/ToolboxApp/App.xaml.cs +++ b/src/ToolboxApp/App.xaml.cs @@ -7,7 +7,7 @@ namespace ToolboxApp; /// /// Interaction logic for App.xaml /// -public partial class App : Application +public partial class App : System.Windows.Application { } diff --git a/src/ToolboxApp/Commands/RelayCommand.cs b/src/ToolboxApp/Commands/RelayCommand.cs new file mode 100644 index 0000000..d923ef0 --- /dev/null +++ b/src/ToolboxApp/Commands/RelayCommand.cs @@ -0,0 +1,82 @@ +using System.Windows.Input; + +namespace ToolboxApp.Commands; + +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Predicate? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + : this(_ => execute(), canExecute is null ? null : _ => canExecute()) + { + } + + public RelayCommand(Action execute, Predicate? 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 _execute; + private readonly Func? _canExecute; + private bool _isRunning; + + public AsyncRelayCommand(Func execute, Func? 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); + } +} diff --git a/src/ToolboxApp/Models/ToolModels.cs b/src/ToolboxApp/Models/ToolModels.cs new file mode 100644 index 0000000..1a8107d --- /dev/null +++ b/src/ToolboxApp/Models/ToolModels.cs @@ -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 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 Categories { get; set; } = []; + public List Tools { get; set; } = []; + public List AutoRunEntries { get; set; } = []; +} + +public sealed class DataEnvelope +{ + public int DataVersion { get; set; } = 1; + public List 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}"; +} diff --git a/src/ToolboxApp/Services/AutoRunService.cs b/src/ToolboxApp/Services/AutoRunService.cs new file mode 100644 index 0000000..f33852a --- /dev/null +++ b/src/ToolboxApp/Services/AutoRunService.cs @@ -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 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 + }; + } +} diff --git a/src/ToolboxApp/Services/ConfigurationService.cs b/src/ToolboxApp/Services/ConfigurationService.cs new file mode 100644 index 0000000..18a3f56 --- /dev/null +++ b/src/ToolboxApp/Services/ConfigurationService.cs @@ -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 LoadAsync(CancellationToken cancellationToken = default) + { + EnsureDirectories(); + + var data = new ToolboxData + { + Settings = await ReadFileAsync(SettingsPath, cancellationToken) ?? new AppSettings(), + Categories = (await ReadEnvelopeAsync(CategoriesPath, cancellationToken)).Items, + Tools = (await ReadEnvelopeAsync(ToolsPath, cancellationToken)).Items, + AutoRunEntries = (await ReadEnvelopeAsync(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 + { + DataVersion = CurrentDataVersion, + Items = data.Categories.OrderBy(category => category.SortOrder).ToList() + }, cancellationToken); + await WriteFileAtomicAsync(ToolsPath, new DataEnvelope + { + DataVersion = CurrentDataVersion, + Items = data.Tools.OrderBy(tool => tool.SortOrder).ToList() + }, cancellationToken); + await WriteFileAtomicAsync(AutoRunPath, new DataEnvelope + { + DataVersion = CurrentDataVersion, + Items = data.AutoRunEntries.OrderBy(entry => entry.SortOrder).ToList() + }, cancellationToken); + } + + public async Task 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 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 ReadFileAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return default; + } + + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken); + } + + private async Task> ReadEnvelopeAsync(string path, CancellationToken cancellationToken) + { + return await ReadFileAsync>(path, cancellationToken) ?? new DataEnvelope { DataVersion = CurrentDataVersion }; + } + + private async Task WriteFileAtomicAsync(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); + } + } +} diff --git a/src/ToolboxApp/Services/HotkeyService.cs b/src/ToolboxApp/Services/HotkeyService.cs new file mode 100644 index 0000000..1acfa51 --- /dev/null +++ b/src/ToolboxApp/Services/HotkeyService.cs @@ -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 _registeredHotkeys = new(); + private HwndSource? _source; + private Func? _onTriggered; + private Action? _log; + private int _nextId = 1000; + private nint _handle; + + public void RegisterAll( + Window window, + IEnumerable tools, + bool enabled, + Func onTriggered, + Action 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 + }; + } +} diff --git a/src/ToolboxApp/Services/PathValidationService.cs b/src/ToolboxApp/Services/PathValidationService.cs new file mode 100644 index 0000000..82dddbd --- /dev/null +++ b/src/ToolboxApp/Services/PathValidationService.cs @@ -0,0 +1,69 @@ +using System.IO; +using ToolboxApp.Models; + +namespace ToolboxApp.Services; + +public sealed class PathValidationService +{ + public int ValidateTools(IEnumerable 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); + } +} diff --git a/src/ToolboxApp/Services/StartupService.cs b/src/ToolboxApp/Services/StartupService.cs new file mode 100644 index 0000000..6b85302 --- /dev/null +++ b/src/ToolboxApp/Services/StartupService.cs @@ -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); + } + } +} diff --git a/src/ToolboxApp/Services/SystemToolService.cs b/src/ToolboxApp/Services/SystemToolService.cs new file mode 100644 index 0000000..8ae682c --- /dev/null +++ b/src/ToolboxApp/Services/SystemToolService.cs @@ -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 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 categories, ICollection 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 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 tools) + { + var order = 0; + foreach (var tool in tools.OrderBy(tool => tool.CategoryId).ThenBy(tool => tool.SortOrder).ThenBy(tool => tool.Name)) + { + tool.SortOrder = order++; + } + } +} diff --git a/src/ToolboxApp/Services/ToolLaunchService.cs b/src/ToolboxApp/Services/ToolLaunchService.cs new file mode 100644 index 0000000..6ee54c0 --- /dev/null +++ b/src/ToolboxApp/Services/ToolLaunchService.cs @@ -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 LaunchAsync( + ToolItem tool, + IReadOnlyCollection allTools, + Action 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 LaunchCombinationAsync( + ToolItem combination, + IReadOnlyCollection allTools, + Action 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 allTools) + { + if (combination.Type != ToolType.Combination) + { + return LaunchResult.Ok(combination); + } + + var stack = new Stack(); + var finalToolIds = new HashSet(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(); + 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 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 + }; + } +} diff --git a/src/ToolboxApp/Services/TrayService.cs b/src/ToolboxApp/Services/TrayService.cs new file mode 100644 index 0000000..ee8794f --- /dev/null +++ b/src/ToolboxApp/Services/TrayService.cs @@ -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 _setHotkeysEnabled; + private readonly Func _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 setHotkeysEnabled, + Func 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(); + } + } +} diff --git a/src/ToolboxApp/ToolboxApp.csproj b/src/ToolboxApp/ToolboxApp.csproj index e3e33e3..4adf208 100644 --- a/src/ToolboxApp/ToolboxApp.csproj +++ b/src/ToolboxApp/ToolboxApp.csproj @@ -6,6 +6,7 @@ enable enable true + true diff --git a/src/ToolboxApp/ViewModels/ObservableObject.cs b/src/ToolboxApp/ViewModels/ObservableObject.cs new file mode 100644 index 0000000..626daf3 --- /dev/null +++ b/src/ToolboxApp/ViewModels/ObservableObject.cs @@ -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(ref T storage, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.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)); + } +}