feat: 初始提交——一键运行快捷轮盘工具(WPF + C#)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>开机启动(HKCU Run 键,无需管理员)</summary>
|
||||
public class AutostartService
|
||||
{
|
||||
private const string RunKeyPath = @"SoftwareMicrosoftWindowsCurrentVersionRun";
|
||||
private const string ValueName = "OneClickRun";
|
||||
|
||||
public bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath);
|
||||
return key?.GetValue(ValueName) is string value && value.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("读取开机启动状态失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetEnabled(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||||
if (enabled)
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(exe)) return false;
|
||||
key.SetValue(ValueName, $"\"{exe}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
}
|
||||
Logger.Info($"开机启动已{(enabled ? "开启" : "关闭")}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("设置开机启动失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
public readonly record struct CommandResult(bool Success, string Message)
|
||||
{
|
||||
public static CommandResult Ok() => new(true, string.Empty);
|
||||
public static CommandResult Fail(string message) => new(false, message);
|
||||
}
|
||||
|
||||
/// <summary>执行四类指令:打开软件 / 文件夹 / 网址 / PowerShell 脚本</summary>
|
||||
public class CommandRunner
|
||||
{
|
||||
public async Task<CommandResult> ExecuteAsync(WheelItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
return item.Type switch
|
||||
{
|
||||
WheelItemType.App => await RunAppAsync(item),
|
||||
WheelItemType.Folder => RunFolder(item.Path),
|
||||
WheelItemType.Url => RunUrl(item.Path),
|
||||
WheelItemType.Script => await RunScriptAsync(item),
|
||||
_ => CommandResult.Fail($"未知指令类型: {item.Type}"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"执行指令失败 [{item.Name}] ({item.Type}: {item.Path})", ex);
|
||||
return CommandResult.Fail($"执行「{item.Name}」失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<CommandResult> RunAppAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return Task.FromResult(CommandResult.Fail($"程序不存在:{item.Path}"));
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = item.Path,
|
||||
Arguments = item.Args ?? string.Empty,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
Logger.Info($"启动软件: {item.Path}");
|
||||
return Task.FromResult(CommandResult.Ok());
|
||||
}
|
||||
catch (Win32Exception ex)
|
||||
{
|
||||
// 常见于用户在 UAC 提示中取消、或系统找不到关联程序
|
||||
Logger.Warn($"启动软件被取消或失败: {item.Path} ({ex.Message})");
|
||||
return Task.FromResult(CommandResult.Fail($"启动「{item.Name}」失败:{ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return CommandResult.Fail($"文件夹不存在:{path}");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
Logger.Info($"打开文件夹: {path}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开文件夹失败: {path}", ex);
|
||||
return CommandResult.Fail($"打开文件夹失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunUrl(string rawUrl)
|
||||
{
|
||||
var url = NormalizeUrl(rawUrl);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CommandResult.Fail("网址为空");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
Logger.Info($"打开网址: {url}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开网址失败: {url}", ex);
|
||||
return CommandResult.Fail($"打开网址失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunScriptAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return CommandResult.Fail($"脚本不存在:{item.Path}");
|
||||
|
||||
var pwsh = PowerShellLocator.Find();
|
||||
if (pwsh == null)
|
||||
return CommandResult.Fail("未找到 PowerShell 7(pwsh.exe)。请安装 PowerShell 7.6.5 后重试。");
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = pwsh,
|
||||
Arguments = PowerShellLocator.BuildScriptArguments(item.Path, item.Args),
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(item.Path)) ?? string.Empty,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
Logger.Info($"运行脚本: {pwsh} -File \"{item.Path}\"");
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return CommandResult.Fail("脚本进程启动失败");
|
||||
await process.WaitForExitAsync();
|
||||
var code = process.ExitCode;
|
||||
if (code == 0) return CommandResult.Ok();
|
||||
Logger.Warn($"脚本退出码 {code}: {item.Path}");
|
||||
return CommandResult.Fail($"脚本「{item.Name}」执行完毕,退出码 {code}(非 0),请检查脚本输出。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"运行脚本失败: {item.Path}", ex);
|
||||
return CommandResult.Fail($"运行脚本失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string NormalizeUrl(string rawUrl)
|
||||
{
|
||||
var url = (rawUrl ?? string.Empty).Trim();
|
||||
if (url.Length == 0) return url;
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
return uri.ToString();
|
||||
if (url.Contains("://")) return url; // 其他协议原样保留
|
||||
return "https://" + url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>配置读写:%APPDATA%\OneClickRun\config.json,原子写入,损坏自动备份重建</summary>
|
||||
public class ConfigService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
public string ConfigDirectory { get; }
|
||||
public string ConfigPath => Path.Combine(ConfigDirectory, "config.json");
|
||||
public AppConfig Current { get; private set; } = new();
|
||||
public bool WasFirstRun { get; private set; }
|
||||
|
||||
public ConfigService(string? directory = null)
|
||||
{
|
||||
ConfigDirectory = directory ??
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun");
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try { Directory.CreateDirectory(ConfigDirectory); } catch { }
|
||||
if (!File.Exists(ConfigPath))
|
||||
{
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(ConfigPath), Options);
|
||||
Current = Repair(cfg);
|
||||
if (cfg == null) BackupBroken("null");
|
||||
WasFirstRun = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("配置文件损坏,已备份并重建默认配置", ex);
|
||||
BackupBroken(ex.GetType().Name);
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
var tmp = ConfigPath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(Current, Options));
|
||||
File.Move(tmp, ConfigPath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("保存配置失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BackupBroken(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
File.Copy(ConfigPath,
|
||||
Path.Combine(ConfigDirectory, $"config.broken-{DateTime.Now:yyyyMMdd-HHmmss}-{reason}.json"),
|
||||
overwrite: true);
|
||||
}
|
||||
catch { /* 备份失败忽略 */ }
|
||||
}
|
||||
|
||||
private static AppConfig Repair(AppConfig? cfg)
|
||||
{
|
||||
cfg ??= new AppConfig();
|
||||
cfg.Version = 1;
|
||||
cfg.General ??= new GeneralSettings();
|
||||
cfg.General.Hotkey ??= new HotkeyBinding();
|
||||
cfg.General.LongPressMs = Math.Clamp(cfg.General.LongPressMs, 300, 1500);
|
||||
cfg.Items ??= new List<WheelItem>();
|
||||
foreach (var item in cfg.Items)
|
||||
{
|
||||
item.Name ??= string.Empty;
|
||||
item.Path ??= string.Empty;
|
||||
item.Args ??= string.Empty;
|
||||
if (string.IsNullOrEmpty(item.Id)) item.Id = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/// <summary>默认配置:4 条示例指令(文档文件夹 / 记事本 / 必应 / 示例脚本)</summary>
|
||||
public static AppConfig CreateDefault()
|
||||
{
|
||||
return new AppConfig
|
||||
{
|
||||
General = new GeneralSettings(),
|
||||
Items = new List<WheelItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "打开文档",
|
||||
Type = WheelItemType.Folder,
|
||||
Path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "记事本",
|
||||
Type = WheelItemType.App,
|
||||
Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "notepad.exe"),
|
||||
},
|
||||
new() { Name = "必应", Type = WheelItemType.Url, Path = "https://www.bing.com" },
|
||||
new()
|
||||
{
|
||||
Name = "示例脚本",
|
||||
Type = WheelItemType.Script,
|
||||
Path = Path.Combine(AppContext.BaseDirectory, "assets", "samples", "hello.ps1"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>全局快捷键服务:RegisterHotKey + WM_HOTKEY 消息挂接</summary>
|
||||
public class HotkeyService : IDisposable
|
||||
{
|
||||
private const int HotkeyId = 0x0C0D;
|
||||
|
||||
private HwndSource? _source;
|
||||
private bool _registered;
|
||||
|
||||
public HotkeyBinding? Current { get; private set; }
|
||||
public bool IsRegistered => _registered;
|
||||
|
||||
/// <summary>快捷键按下(MOD_NOREPEAT,一次按压仅触发一次)</summary>
|
||||
public event Action? HotkeyPressed;
|
||||
|
||||
/// <summary>注册失败(可能与其他程序冲突)</summary>
|
||||
public event Action? RegistrationFailed;
|
||||
|
||||
public void Attach(Window host)
|
||||
{
|
||||
if (_source != null) return;
|
||||
var hwnd = new WindowInteropHelper(host).Handle; // 强制创建句柄
|
||||
_source = HwndSource.FromHwnd(hwnd);
|
||||
_source?.AddHook(WndProc);
|
||||
}
|
||||
|
||||
public void Update(HotkeyBinding? binding)
|
||||
{
|
||||
Unregister();
|
||||
Current = binding?.IsValid == true ? binding : null;
|
||||
if (Current == null || _source == null) return;
|
||||
|
||||
var vk = (uint)HotkeyFormat.VirtualKeyOf(Current.Key);
|
||||
if (vk == 0) return;
|
||||
var mods = Win32Interop.ModifiersToWin32(Current.Modifiers) | Win32Interop.MOD_NOREPEAT;
|
||||
_registered = Win32Interop.RegisterHotKey(_source.Handle, HotkeyId, mods, vk);
|
||||
if (_registered)
|
||||
Logger.Info($"全局快捷键注册成功: {HotkeyFormat.Format(Current)}");
|
||||
else
|
||||
{
|
||||
Logger.Warn($"全局快捷键注册失败: {HotkeyFormat.Format(Current)}");
|
||||
RegistrationFailed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
if (_registered && _source != null)
|
||||
{
|
||||
Win32Interop.UnregisterHotKey(_source.Handle, HotkeyId);
|
||||
_registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == Win32Interop.WM_HOTKEY && wParam.ToInt64() == HotkeyId)
|
||||
{
|
||||
HotkeyPressed?.Invoke();
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>判断快捷键组合(主键 + 所有修饰键)当前是否处于按下状态</summary>
|
||||
public static bool IsComboDown(HotkeyBinding? binding)
|
||||
{
|
||||
if (binding == null || !binding.IsValid) return false;
|
||||
if (!Win32Interop.IsKeyDown(HotkeyFormat.VirtualKeyOf(binding.Key))) return false;
|
||||
return Win32Interop.AreComboModifiersDown(binding.Modifiers);
|
||||
}
|
||||
|
||||
public void Dispose() => Unregister();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>轻量文件日志(按天滚动,位于 %APPDATA%\OneClickRun\logs)</summary>
|
||||
public static class Logger
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static readonly string Dir =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun", "logs");
|
||||
|
||||
public static string LogDirectory => Dir;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
try { Directory.CreateDirectory(Dir); } catch { /* 日志目录创建失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message, Exception? ex = null) =>
|
||||
Write("ERROR", ex == null ? message : message + " | " + ex);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
var file = Path.Combine(Dir, $"app-{DateTime.Now:yyyyMMdd}.log");
|
||||
File.AppendAllText(file,
|
||||
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch { /* 日志失败不抛出 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>定位 PowerShell 7(pwsh.exe)并构造脚本启动参数</summary>
|
||||
public static class PowerShellLocator
|
||||
{
|
||||
/// <summary>依次从 PATH、标准安装目录查找 pwsh.exe</summary>
|
||||
public static string? Find()
|
||||
{
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in pathVar.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir.Trim(), "pwsh.exe");
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string[] candidates =
|
||||
{
|
||||
Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"),
|
||||
Path.Combine(localAppData, "Programs", "PowerShell", "7", "pwsh.exe"),
|
||||
};
|
||||
foreach (var c in candidates)
|
||||
if (File.Exists(c)) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? GetVersion(string? pwshPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pwshPath) || !File.Exists(pwshPath)) return null;
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = pwshPath,
|
||||
Arguments = "-NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process == null) return null;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
return string.IsNullOrWhiteSpace(output) ? null : output.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"获取 PowerShell 版本失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>构造 pwsh 启动参数(脚本路径加引号,兼容含空格路径)</summary>
|
||||
public static string BuildScriptArguments(string scriptPath, string? extraArgs)
|
||||
{
|
||||
var quoted = "\"" + scriptPath + "\"";
|
||||
return string.IsNullOrWhiteSpace(extraArgs)
|
||||
? $"-NoProfile -ExecutionPolicy Bypass -File {quoted}"
|
||||
: $"-NoProfile -ExecutionPolicy Bypass -File {quoted} {extraArgs.Trim()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>日间/黑夜/跟随系统主题切换(切换合并的主题资源字典)</summary>
|
||||
public class ThemeService
|
||||
{
|
||||
private const string LightUri = "Themes/Light.xaml";
|
||||
private const string DarkUri = "Themes/Dark.xaml";
|
||||
|
||||
private ThemeMode _mode = ThemeMode.System;
|
||||
private bool _systemEventsHooked;
|
||||
|
||||
public ThemeMode Mode => _mode;
|
||||
|
||||
public void Apply(ThemeMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
var dark = mode switch
|
||||
{
|
||||
ThemeMode.Light => false,
|
||||
ThemeMode.Dark => true,
|
||||
_ => SystemUsesDark(),
|
||||
};
|
||||
|
||||
var app = Application.Current;
|
||||
var dictionaries = app.Resources.MergedDictionaries;
|
||||
for (var i = dictionaries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var source = dictionaries[i].Source;
|
||||
if (source != null &&
|
||||
(source.OriginalString == LightUri || source.OriginalString == DarkUri))
|
||||
dictionaries.RemoveAt(i);
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = new Uri(dark ? DarkUri : LightUri, UriKind.Relative) });
|
||||
Logger.Info($"主题已应用: {mode} → {(dark ? "黑夜" : "日间")}");
|
||||
|
||||
HookSystemEvents();
|
||||
}
|
||||
|
||||
private void HookSystemEvents()
|
||||
{
|
||||
if (_systemEventsHooked) return;
|
||||
_systemEventsHooked = true;
|
||||
SystemEvents.UserPreferenceChanged += (_, _) =>
|
||||
{
|
||||
if (_mode != ThemeMode.System) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
dispatcher?.BeginInvoke(() => Apply(ThemeMode.System));
|
||||
};
|
||||
}
|
||||
|
||||
public static bool SystemUsesDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = Registry.GetValue(
|
||||
@"HKEY_CURRENT_USERSoftwareMicrosoftWindowsCurrentVersionThemesPersonalize",
|
||||
"AppsUseLightTheme", 1);
|
||||
return value is int i && i == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>非阻塞的右下角提示(线程安全,自动切回 UI 线程)</summary>
|
||||
public static class ToastService
|
||||
{
|
||||
private static ToastWindow? _window;
|
||||
|
||||
public static void Show(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null) return;
|
||||
if (!dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.BeginInvoke(() => Show(message));
|
||||
return;
|
||||
}
|
||||
if (_window == null)
|
||||
{
|
||||
_window = new ToastWindow();
|
||||
_window.Show();
|
||||
}
|
||||
_window.ShowMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using WinForms = System.Windows.Forms;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 轮盘窗口管理器:三种呼出模式(点击/长按/按住)与三种选择模式(单击/双击/滑动)的状态机。
|
||||
/// 通过 30ms 轮询 GetAsyncKeyState 判断快捷键松开;长按达到阈值后才显示轮盘。
|
||||
/// </summary>
|
||||
public class WheelWindowManager : IDisposable
|
||||
{
|
||||
private readonly CommandRunner _runner;
|
||||
private readonly Func<AppConfig> _config;
|
||||
private readonly RadialMenuWindow _window = new();
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly GlobalMouseHook _mouseHook = new();
|
||||
|
||||
private bool _shown;
|
||||
private bool _engaged;
|
||||
private bool _longPressFired;
|
||||
private DateTime _engagedAt;
|
||||
|
||||
/// <summary>轮盘全局开关(关闭时快捷键不再呼出)</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>内部调试:快照导出时访问轮盘窗口</summary>
|
||||
internal RadialMenuWindow Window => _window;
|
||||
|
||||
public WheelWindowManager(CommandRunner runner, Func<AppConfig> config)
|
||||
{
|
||||
_runner = runner;
|
||||
_config = config;
|
||||
_window.SectorPicked += OnSectorPicked;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_timer.Tick += OnTick;
|
||||
_mouseHook.LeftButtonDown += OnGlobalLeftButtonDown;
|
||||
}
|
||||
|
||||
/// <summary>全局快捷键按下(WM_HOTKEY,MOD_NOREPEAT 保证单次触发)</summary>
|
||||
public void OnHotkeyPressed()
|
||||
{
|
||||
var cfg = _config();
|
||||
if (!Enabled || !cfg.General.WheelEnabled) return;
|
||||
|
||||
switch (cfg.General.TriggerMode)
|
||||
{
|
||||
case TriggerMode.Click:
|
||||
if (_shown) Hide();
|
||||
else Show();
|
||||
break;
|
||||
case TriggerMode.Hold:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
Show();
|
||||
break;
|
||||
case TriggerMode.LongPress:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
var cfg = _config();
|
||||
if (_engaged)
|
||||
{
|
||||
if (!HotkeyService.IsComboDown(cfg.General.Hotkey))
|
||||
OnHotkeyReleased();
|
||||
else if (!_shown && !_longPressFired && cfg.General.TriggerMode == TriggerMode.LongPress &&
|
||||
(DateTime.UtcNow - _engagedAt).TotalMilliseconds >= cfg.General.LongPressMs)
|
||||
{
|
||||
_longPressFired = true;
|
||||
Show();
|
||||
}
|
||||
}
|
||||
if (_shown && Win32Interop.IsKeyDown(Win32Interop.VK_ESCAPE))
|
||||
Hide();
|
||||
if (!_engaged && !_shown)
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
private void OnHotkeyReleased()
|
||||
{
|
||||
_engaged = false;
|
||||
var cfg = _config();
|
||||
if (!_shown) return; // 长按未到阈值,忽略本次按压
|
||||
|
||||
if (cfg.General.TriggerMode == TriggerMode.Hold)
|
||||
{
|
||||
if (cfg.General.SelectionMode == SelectionMode.Swipe)
|
||||
{
|
||||
var index = _window.HitTestScreenDip(DpiUtil.GetCursorPosDip());
|
||||
if (index is int i && i >= 0 && i < cfg.Items.Count)
|
||||
{
|
||||
ExecuteAndHide(cfg.Items[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Hide(); // 按住显示:松开即隐藏
|
||||
}
|
||||
// 长按显示:松开不影响已显示的轮盘,等待选择 / Esc / 点击外部
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_shown) return;
|
||||
var cfg = _config();
|
||||
var center = cfg.General.WheelPosition == WheelPosition.Mouse
|
||||
? DpiUtil.GetCursorPosDip()
|
||||
: GetPrimaryScreenCenterDip();
|
||||
|
||||
// 滑动选择仅在"按住显示"下有效,其余情况回退为单击
|
||||
var selectionMode = cfg.General.SelectionMode;
|
||||
if (selectionMode == SelectionMode.Swipe && cfg.General.TriggerMode != TriggerMode.Hold)
|
||||
selectionMode = SelectionMode.SingleClick;
|
||||
|
||||
_window.SelectionMode = selectionMode;
|
||||
_window.ShowWheel(cfg.Items, HubHint(cfg), center);
|
||||
ClampToVirtualScreen();
|
||||
_window.Show();
|
||||
_shown = true;
|
||||
_mouseHook.Start();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (!_shown) return;
|
||||
_shown = false;
|
||||
_window.Hide();
|
||||
_mouseHook.Stop();
|
||||
if (!_engaged) _timer.Stop();
|
||||
}
|
||||
|
||||
private void OnSectorPicked(int index)
|
||||
{
|
||||
var items = _config().Items;
|
||||
if (index < 0 || index >= items.Count) return;
|
||||
ExecuteAndHide(items[index]);
|
||||
}
|
||||
|
||||
private async void ExecuteAndHide(WheelItem item)
|
||||
{
|
||||
Hide();
|
||||
var result = await _runner.ExecuteAsync(item);
|
||||
if (!result.Success)
|
||||
ToastService.Show(result.Message);
|
||||
}
|
||||
|
||||
private void OnGlobalLeftButtonDown(Point screenPx)
|
||||
{
|
||||
if (!_shown) return;
|
||||
var dip = DpiUtil.PxToDip(screenPx);
|
||||
if (dip.X < _window.Left || dip.X > _window.Left + _window.Width ||
|
||||
dip.Y < _window.Top || dip.Y > _window.Top + _window.Height)
|
||||
Hide();
|
||||
}
|
||||
|
||||
private static Point GetPrimaryScreenCenterDip()
|
||||
{
|
||||
var screen = WinForms.Screen.PrimaryScreen;
|
||||
if (screen == null)
|
||||
{
|
||||
var wa = SystemParameters.WorkArea;
|
||||
return new Point(wa.Left + wa.Width / 2.0, wa.Top + wa.Height / 2.0);
|
||||
}
|
||||
var bounds = screen.Bounds;
|
||||
var px = new Point(bounds.X + bounds.Width / 2.0, bounds.Y + bounds.Height / 2.0);
|
||||
return DpiUtil.PxToDip(px);
|
||||
}
|
||||
|
||||
private void ClampToVirtualScreen()
|
||||
{
|
||||
var left = SystemParameters.VirtualScreenLeft;
|
||||
var top = SystemParameters.VirtualScreenTop;
|
||||
var width = SystemParameters.VirtualScreenWidth;
|
||||
var height = SystemParameters.VirtualScreenHeight;
|
||||
_window.Left = Math.Clamp(_window.Left, left + 4, left + width - _window.Width - 4);
|
||||
_window.Top = Math.Clamp(_window.Top, top + 4, top + height - _window.Height - 4);
|
||||
}
|
||||
|
||||
private static string HubHint(AppConfig cfg)
|
||||
{
|
||||
var (trigger, selection) = (cfg.General.TriggerMode, cfg.General.SelectionMode);
|
||||
if (selection == SelectionMode.Swipe) return "松开执行";
|
||||
var select = selection == SelectionMode.DoubleClick ? "双击选择" : "单击选择";
|
||||
// 保持单行短文本,确保中心提示行高度恒定、标题始终居中
|
||||
return trigger == TriggerMode.Hold ? "按住 · " + select : select + " · Esc";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_mouseHook.Dispose();
|
||||
_window.Close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user