feat: 搭建工具箱核心服务层

新增工具、分类、组合、自动运行和日志等基础模型。

实现本地 JSON 配置读写、默认系统工具恢复、路径校验、统一启动服务、自动运行服务、托盘服务、开机自启服务和全局快捷键注册服务,为后续界面集成提供稳定边界。
This commit is contained in:
2026-05-27 13:41:09 +08:00
parent e75faaf03a
commit fde1c6bc0f
13 changed files with 1503 additions and 1 deletions

View File

@@ -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
{ {
} }

View 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);
}
}

View 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}";
}

View 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
};
}
}

View 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);
}
}
}

View 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
};
}
}

View 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);
}
}

View 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);
}
}
}

View 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++;
}
}
}

View 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
};
}
}

View 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();
}
}
}

View File

@@ -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>

View 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));
}
}