初始提交:WinUI 3 一键运行轮盘工具

This commit is contained in:
2026-08-30 01:37:56 +08:00
commit 83011e5d29
58 changed files with 4373 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# ===== 构建输出 =====
[Bb]in/
[Oo]bj/
dist/
out/
artifacts/
publish/
TestResults/
# ===== IDE / 编辑器 =====
.vs/
.idea/
.vscode/
*.user
*.suo
*.userosscache
*.sln.docstates
# ===== 临时与日志 =====
*.tmp
*.log
*.bak
*.corrupt-*
Thumbs.db
Desktop.ini
.DS_Store
# ===== NuGet / 测试产物 =====
*.nupkg
packages/
*.trx
*.coverage
.coverage
*.coveragexml
+43
View File
@@ -0,0 +1,43 @@
# Changelog
本项目的所有重要变更都会记录在此文件中。
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/)。
## [Unreleased]
### Added
- 新增系统托盘图标:左键单击托盘图标显示设置界面;右键菜单提供
「启用/关闭轮盘」(控制轮盘全局开关)、「设置」「退出」三个选项
- 新增端到端测试脚本 scripts\e2e.ps1,覆盖 11 个场景:点击/长按/按住呼出、
Esc 关闭、单击与双击执行指令、滑动选择、鼠标位置显示、脚本执行、
开机启动注册表写入、轮盘全局开关
- 快捷指令「运行脚本」支持 .cmd / .bat 批处理文件(通过 cmd /c 执行),
文件选择器同步移除不再支持的 .psm1
### Changed
- 点击设置窗口关闭按钮不再退出程序,改为最小化到系统托盘
### Fixed
- 修复全局快捷键注册失败(Win32 错误 1408):RegisterHotKey 的 hWnd 必须
属于调用线程,现在把注册/注销封送到快捷键消息窗口线程执行
- 修复开机启动写入到错误注册表位置:Run 键路径缺少反斜杠
- 修复「长按时长」被滑块初始化覆盖:BehaviorPage 构造时设置 Slider.Value
触发 ValueChanged,导致已保存的 400ms 被改回 200ms
## [0.1.0] - 2026-08-30
### Added
- 首个可用版本:WinUI 3 轮盘快捷启动工具
- 全局快捷键呼出轮盘(默认 Ctrl+Alt+Q),点击显示/长按显示/按住显示三种呼出方式
- 单击/双击/滑动三种指令选择方式,轮盘可显示在屏幕中心或鼠标位置
- 快捷指令:打开软件、打开文件夹、打开网址、运行 PowerShell 脚本
- 设置主界面:常规、呼出与快捷键、快捷指令、关于四个页面
- 开机启动、轮盘全局开关、日间/夜间/跟随系统主题
- OneClickRun.Core 纯逻辑层与 62 个单元测试
- 冒烟测试脚本 scripts\smoke.ps1
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.1.0</Version>
<Product>一键运行 OneClickRun</Product>
</PropertyGroup>
</Project>
+69
View File
@@ -0,0 +1,69 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneClickRun.Core", "src\OneClickRun.Core\OneClickRun.Core.csproj", "{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneClickRun.Core.Tests", "tests\OneClickRun.Core.Tests\OneClickRun.Core.Tests.csproj", "{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneClickRun.App", "src\OneClickRun.App\OneClickRun.App.csproj", "{AB82B886-1BB7-4430-94E9-104C40348F33}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|x64.ActiveCfg = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|x64.Build.0 = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|x86.ActiveCfg = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Debug|x86.Build.0 = Debug|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|Any CPU.Build.0 = Release|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|x64.ActiveCfg = Release|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|x64.Build.0 = Release|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|x86.ActiveCfg = Release|Any CPU
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E}.Release|x86.Build.0 = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|x64.Build.0 = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Debug|x86.Build.0 = Debug|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|Any CPU.Build.0 = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|x64.ActiveCfg = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|x64.Build.0 = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|x86.ActiveCfg = Release|Any CPU
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2}.Release|x86.Build.0 = Release|Any CPU
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|Any CPU.ActiveCfg = Debug|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|Any CPU.Build.0 = Debug|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|x64.ActiveCfg = Debug|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|x64.Build.0 = Debug|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|x86.ActiveCfg = Debug|x86
{AB82B886-1BB7-4430-94E9-104C40348F33}.Debug|x86.Build.0 = Debug|x86
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|Any CPU.ActiveCfg = Release|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|Any CPU.Build.0 = Release|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|x64.ActiveCfg = Release|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|x64.Build.0 = Release|x64
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|x86.ActiveCfg = Release|x86
{AB82B886-1BB7-4430-94E9-104C40348F33}.Release|x86.Build.0 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FD7B90E4-12AB-4C6F-A906-305CDE27B15E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5E9E323A-8D8E-4F7F-AADF-3179932DF0D2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{AB82B886-1BB7-4430-94E9-104C40348F33} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+74
View File
@@ -0,0 +1,74 @@
# 一键运行 OneClickRun
一键运行工具:以游戏快捷轮盘的形式,通过全局快捷键呼出一个圆形扇区轮盘,
快速启动软件、打开文件夹、打开网址或运行脚本。基于 WinUI 3 + C#.NET 8),
面向 Windows 10 / Windows 11。
## 功能特性
- 圆形轮盘,扇区数量与快捷指令数量一致(上限 12 个,默认 5 个演示指令)
- 全局快捷键呼出/隐藏轮盘(默认 Ctrl+Alt+Q
- 三种呼出方式:点击显示、长按显示、按住显示(松开即隐藏)
- 三种选择方式:单击选择、双击选择、滑动选择(仅按住显示时有效)
- 轮盘显示位置:屏幕中心 / 鼠标位置
- 指令类型:打开软件、打开文件夹、打开网址、运行 PowerShell 脚本(.ps1/.cmd/.bat
- 主设置界面:常规(开机启动、轮盘全局开关、主题)、行为、快捷指令、关于
- 系统托盘:点击设置窗口关闭按钮最小化到托盘,左键点击托盘图标恢复设置窗口,
右键菜单提供「启用/关闭轮盘」「设置」「退出」
- 日间/夜间/跟随系统主题
- 轮盘点击外部、Esc 或 8 秒超时自动关闭
## 目录结构
One_click_run/
├── OneClickRun.sln
├── Directory.Build.props
├── requirements.md # 开发需求文档
├── scripts/
│ ├── smoke.ps1 # 冒烟测试:启动→呼出轮盘→Esc 关闭
│ ├── e2e.ps1 # 端到端测试:11 个场景
│ ├── gen-icon.ps1 # 生成应用图标
│ └── probe-hotkey.ps1 # 探测系统快捷键占用
├── src/
│ ├── OneClickRun.Core/ # 纯逻辑:设置、指令执行、轮盘几何、快捷键解析
│ └── OneClickRun.App/ # WinUI 3 应用:窗口、页面、控件、服务
└── tests/
└── OneClickRun.Core.Tests/ # 单元测试(xUnit
## 环境要求
- Windows 1019041 及以上)或 Windows 11
- .NET 8 SDK
- Windows App SDK 2.4.0NuGet 自动还原,应用已配置自包含 Windows App SDK
## 构建与运行
# 构建
dotnet build OneClickRun.sln
# 运行(调试构建输出)
src\OneClickRun.App\bin\x64\Debug\net8.0-windows10.0.19041.0\win-x64\OneClickRun.exe
## 使用说明
- 默认快捷键 Ctrl+Alt+Q 呼出轮盘;在「呼出与快捷键」页可修改
- 「常规」页可设置开机启动、轮盘全局开关与主题模式
- 「快捷指令」页可新增/编辑/删除/排序指令,并支持「测试运行」
- 点击设置窗口右上角关闭按钮不会退出程序,而是最小化到系统托盘;
左键单击托盘图标恢复设置窗口,右键菜单可切换轮盘全局开关、打开设置或退出程序
- 设置保存在 %APPDATA%\OneClickRun\settings.json
- 日志位于 %APPDATA%\OneClickRun\logs\app.log
## 测试
# 单元测试(62 个)
dotnet test OneClickRun.sln
# 冒烟测试(需要图形会话)
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\smoke.ps1
# 端到端测试(11 个场景;需要图形会话)
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\e2e.ps1
注意:e2e.ps1 会临时写入并恢复 %APPDATA%\OneClickRun\settings.json、
临时写入并恢复开机启动注册表项,并会启动/关闭记事本等测试进程。
+48
View File
@@ -0,0 +1,48 @@
# 一键运行工具开发需求
## 概述
一键运行工具是采用游戏中常见的快捷轮盘的形式,快速启动软件、打开文件夹、打开链接等快捷指令。
## 运行平台与技术栈
- 运行平台:Windows平台(Win10Win11
- 技术栈:WinUI3 + C#
## 功能需求
- 轮盘:在屏幕上一个圆环区域分割出多个扇形区域,每个区域分配一个操作或指令
- 轮盘的呼出和隐藏:默认轮盘隐藏,通过指定快捷键呼出轮盘,选择轮盘上的指令后,自动隐藏轮盘
- 轮盘支持的指令:打开软件、打开指定文件夹、打开指定网站、运行指定的脚本(powershell)等
- 主界面:提供一个主界面,用于设置软件的各项功能,具体设置参考后续
## 快捷指令
- 打开软件:指定程序路径
- 打开文件夹:指定文件夹路径
- 打开网址:以默认浏览器打开指定网址
- 运行脚本:指定脚本文件
## 系统设置
系统的设置项包括:
- 开机启动开关
- 轮盘全局开关:关闭时,不再呼出轮盘
- 轮盘显示位置:可选项包括:
- 屏幕中心:呼出轮盘时在屏幕中心显示
- 鼠标位置:呼出轮盘时在鼠标位置显示
- 全局快捷键:呼出轮盘的快捷键,全局有效
- 呼出轮盘操作:
- 点击显示:按一次快捷键即显示轮盘
- 长按显示:长按快捷键一定时间显示轮盘
- 按住显示:按住快捷键时显示轮盘,松开即隐藏
- 轮盘指令选择操作:
- 单击选择:单击轮盘指令选择
- 双击选择:双击轮盘指令选择
- 滑动选择:选择鼠标滑动方向所指的指令(仅在按住显示轮盘时有效,以松开快捷键时,鼠标所在轮盘的方位选择指定的指令
- 日间/黑夜模式切换:日间/黑夜/跟随系统
- 轮盘指令设置,设置轮盘的各项指令
## UI风格
以现代简约风格为主,支持日间/黑夜模式
+329
View File
@@ -0,0 +1,329 @@
# End-to-end test: summon modes (click / long-press / hold), execute-by-click, swipe select, mouse position, global switch.
param(
[string]$Exe = "src/OneClickRun.App/bin/x64/Debug/net8.0-windows10.0.19041.0/win-x64/OneClickRun.exe"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$exePath = Join-Path $root $Exe
if (-not (Test-Path $exePath)) { throw "App not found: $exePath" }
$settingsDir = Join-Path $env:APPDATA "OneClickRun"
$settingsFile = Join-Path $settingsDir "settings.json"
$settingsBackup = $null
if (Test-Path $settingsFile) {
$settingsBackup = $settingsFile + ".e2e-backup"
Copy-Item $settingsFile $settingsBackup -Force
}
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class E2EWin {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
}
"@
$script:Proc = $null
$script:TargetPid = 0
$script:WheelHwnd = [IntPtr]::Zero
$script:WheelRect = New-Object E2EWin+RECT
$script:WheelVisible = $false
function Get-WheelInfo {
$script:WheelVisible = $false
$script:WheelHwnd = [IntPtr]::Zero
$cb = [E2EWin+EnumWindowsProc]{
param($h, $l)
$wpid = 0
[void][E2EWin]::GetWindowThreadProcessId($h, [ref]$wpid)
if ($wpid -eq $script:TargetPid) {
$sb = New-Object System.Text.StringBuilder 256
[void][E2EWin]::GetWindowText($h, $sb, 256)
if ($sb.ToString() -like '*OneClickRun Wheel*') {
$script:WheelHwnd = $h
$rect = New-Object E2EWin+RECT
[void][E2EWin]::GetWindowRect($h, [ref]$rect)
$script:WheelRect = $rect
$script:WheelVisible = [E2EWin]::IsWindowVisible($h)
}
}
return $true
}
[void][E2EWin]::EnumWindows($cb, [IntPtr]::Zero)
return $script:WheelVisible
}
function Write-Settings([hashtable]$s) {
$s | ConvertTo-Json -Depth 10 | Set-Content -Path $settingsFile -Encoding UTF8
}
function Start-App {
$script:Proc = Start-Process -FilePath $exePath -PassThru
$script:TargetPid = $script:Proc.Id
Start-Sleep -Seconds 4
if ($script:Proc.HasExited) { throw "App exited early with code $($script:Proc.ExitCode)" }
}
function Stop-App {
if ($script:Proc -and -not $script:Proc.HasExited) { Stop-Process -Id $script:TargetPid -Force }
Start-Sleep -Milliseconds 400
}
function Key-Down([byte]$vk) { [E2EWin]::keybd_event($vk, 0, 0, [UIntPtr]::Zero) }
function Key-Up([byte]$vk) { [E2EWin]::keybd_event($vk, 0, 2, [UIntPtr]::Zero) }
function Press-Hotkey { Key-Down 0x11; Key-Down 0x12; Key-Down 0x51; Start-Sleep -Milliseconds 120; Key-Up 0x51; Key-Up 0x12; Key-Up 0x11 }
function Hold-Hotkey-Down { Key-Down 0x11; Key-Down 0x12; Key-Down 0x51 }
function Hold-Hotkey-Up { Key-Up 0x51; Key-Up 0x12; Key-Up 0x11 }
function Press-Esc { Key-Down 0x1B; Start-Sleep -Milliseconds 60; Key-Up 0x1B }
function Assert([bool]$cond, [string]$msg) { if (-not $cond) { throw "Assertion failed: $msg" } }
function Get-SectorPoint([int]$index, [int]$count) {
$w = $script:WheelRect.Right - $script:WheelRect.Left
$scale = $w / 560.0
$cx = ($script:WheelRect.Left + $script:WheelRect.Right) / 2.0
$cy = ($script:WheelRect.Top + $script:WheelRect.Bottom) / 2.0
$span = 360.0 / $count
$mid = ($index + 0.5) * $span
$rad = $mid * [Math]::PI / 180.0
$r = 177.5 * $scale
return @{ X = [int][Math]::Round($cx + $r * [Math]::Sin($rad)); Y = [int][Math]::Round($cy - $r * [Math]::Cos($rad)) }
}
function Click-At([int]$x, [int]$y) {
[void][E2EWin]::SetCursorPos($x, $y)
Start-Sleep -Milliseconds 120
[E2EWin]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 80
[E2EWin]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 500
}
function Click-Once([int]$x, [int]$y) {
[void][E2EWin]::SetCursorPos($x, $y)
Start-Sleep -Milliseconds 60
[E2EWin]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[E2EWin]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 30
}
function New-NotepadCommand { @{ Name = "Notepad"; ActionType = 0; Target = (Join-Path $env:WINDIR 'System32\notepad.exe'); IconGlyph = "A"; ColorIndex = 0 } }
function New-CalcCommand { @{ Name = "Calc"; ActionType = 0; Target = (Join-Path $env:WINDIR 'System32\calc.exe'); IconGlyph = "B"; ColorIndex = 1 } }
function New-BingCommand { @{ Name = "Bing"; ActionType = 2; Target = "https://www.bing.com"; IconGlyph = "C"; ColorIndex = 2 } }
function New-BaseSettings([int]$summon, [int]$selection, [int]$position, [bool]$enabled, [bool]$startWithWindows = $false) {
return @{
SchemaVersion = 1; StartWithWindows = $startWithWindows; WheelEnabled = $enabled
WheelPosition = $position; Hotkey = @{ Modifiers = 3; Key = 81 }
SummonMode = $summon; LongPressDelayMs = 400; SelectionMode = $selection; ThemeMode = 2
Commands = @((New-NotepadCommand), (New-CalcCommand), (New-BingCommand))
}
}
$failed = 0
$results = New-Object System.Collections.Generic.List[string]
function Run-Test([string]$name, [scriptblock]$body) {
try {
& $body
$results.Add("PASS $name")
Write-Host "PASS $name"
} catch {
$script:failed++
$results.Add("FAIL $name :: $($_.Exception.Message)")
Write-Host "FAIL $name :: $($_.Exception.Message)" -ForegroundColor Red
} finally {
Stop-App
}
}
try {
Run-Test "click summon: show then toggle-hide" {
Remove-Item $settingsFile -ErrorAction SilentlyContinue
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (-not (Get-WheelInfo)) "second hotkey press did not hide wheel"
}
Run-Test "click summon: Esc dismisses wheel" {
Remove-Item $settingsFile -ErrorAction SilentlyContinue
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
Press-Esc; Start-Sleep -Milliseconds 500
Assert (-not (Get-WheelInfo)) "wheel still visible after Esc"
}
Run-Test "click execute: sector 0 starts Notepad" {
Remove-Item $settingsFile -ErrorAction SilentlyContinue
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
$p = Get-SectorPoint 0 5
Click-At $p.X $p.Y
Start-Sleep -Milliseconds 800
Assert (-not (Get-WheelInfo)) "wheel not hidden after execute"
$np = Get-Process notepad -ErrorAction SilentlyContinue
Assert ($null -ne $np) "Notepad process was not started"
$np | Stop-Process -Force
}
Run-Test "long-press summon: shows after 400ms and stays after release" {
Write-Settings (New-BaseSettings 1 0 0 $true)
Start-App
Hold-Hotkey-Down
Start-Sleep -Milliseconds 300
Assert (-not (Get-WheelInfo)) "wheel shown too early (300ms)"
Start-Sleep -Milliseconds 250
Assert (Get-WheelInfo) "wheel not shown after 400ms long press"
Hold-Hotkey-Up
Start-Sleep -Milliseconds 400
Assert (Get-WheelInfo) "wheel disappeared after release (should stay)"
Press-Esc; Start-Sleep -Milliseconds 400
Assert (-not (Get-WheelInfo)) "Esc did not close wheel"
}
Run-Test "hold summon: visible while held, hidden on release" {
Write-Settings (New-BaseSettings 2 0 0 $true)
Start-App
Hold-Hotkey-Down
Start-Sleep -Milliseconds 400
Assert (Get-WheelInfo) "wheel not visible while hotkey held"
Hold-Hotkey-Up
Start-Sleep -Milliseconds 400
Assert (-not (Get-WheelInfo)) "wheel still visible after release"
}
Run-Test "swipe select: hold + mouse up, release executes Notepad" {
Write-Settings (New-BaseSettings 2 2 0 $true)
Start-App
Hold-Hotkey-Down
Start-Sleep -Milliseconds 400
Assert (Get-WheelInfo) "wheel not visible while hotkey held"
$p = Get-SectorPoint 0 3
[void][E2EWin]::SetCursorPos($p.X, $p.Y)
Start-Sleep -Milliseconds 150
Hold-Hotkey-Up
Start-Sleep -Milliseconds 800
Assert (-not (Get-WheelInfo)) "wheel still visible after release"
$np = Get-Process notepad -ErrorAction SilentlyContinue
Assert ($null -ne $np) "swipe did not execute Notepad"
$np | Stop-Process -Force
}
Run-Test "wheel position: mouse cursor" {
Write-Settings (New-BaseSettings 0 0 1 $true)
Start-App
[void][E2EWin]::SetCursorPos(400, 300)
Start-Sleep -Milliseconds 150
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
$cx = ($script:WheelRect.Left + $script:WheelRect.Right) / 2.0
$cy = ($script:WheelRect.Top + $script:WheelRect.Bottom) / 2.0
$d = [Math]::Sqrt(($cx - 400) * ($cx - 400) + ($cy - 300) * ($cy - 300))
Assert ($d -lt 12) "wheel center offset from cursor by $([int]$d)px"
Press-Esc
}
Run-Test "double-click selection: first click pending, second click executes" {
Write-Settings (New-BaseSettings 0 1 0 $true)
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
$p = Get-SectorPoint 0 3
Click-Once $p.X $p.Y
Start-Sleep -Milliseconds 150
Assert (Get-WheelInfo) "wheel hidden after first click in double-click mode"
$np = Get-Process notepad -ErrorAction SilentlyContinue
Assert ($null -eq $np) "Notepad started after single click in double-click mode"
Click-Once $p.X $p.Y
Start-Sleep -Milliseconds 600
Assert (-not (Get-WheelInfo)) "wheel not hidden after double click"
$np = Get-Process notepad -ErrorAction SilentlyContinue
Assert ($null -ne $np) "Notepad not started after double click"
$np | Stop-Process -Force
}
Run-Test "autostart: registry Run value written at launch" {
Write-Settings (New-BaseSettings 0 0 0 $true $true)
Start-App
$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
$prev = (Get-ItemProperty -Path $runKey -Name OneClickRun -ErrorAction SilentlyContinue).OneClickRun
try {
$cur = (Get-ItemProperty -Path $runKey -Name OneClickRun -ErrorAction SilentlyContinue).OneClickRun
Assert ($null -ne $cur) "autostart registry value missing"
Assert ($cur -like '*OneClickRun*') "unexpected autostart value: $cur"
} finally {
if ($null -eq $prev) {
Remove-ItemProperty -Path $runKey -Name OneClickRun -ErrorAction SilentlyContinue
} else {
Set-ItemProperty -Path $runKey -Name OneClickRun -Value $prev
}
}
}
Run-Test "script execute: cmd and ps1 sectors run" {
$cmdPath = Join-Path $env:TEMP "ocr-e2e-run.cmd"
$ps1Path = Join-Path $env:TEMP "ocr-e2e-run.ps1"
$cmdMarker = Join-Path $env:TEMP "ocr-e2e-cmd-marker.txt"
$ps1Marker = Join-Path $env:TEMP "ocr-e2e-ps1-marker.txt"
Remove-Item $cmdMarker, $ps1Marker -ErrorAction SilentlyContinue
$cmdLine = 'echo ok > "' + $cmdMarker + '"'
$ps1Line = "Set-Content -Path '" + $ps1Marker + "' -Value ok"
Set-Content -Path $cmdPath -Value @('@echo off', $cmdLine) -Encoding ASCII
Set-Content -Path $ps1Path -Value $ps1Line -Encoding ASCII
$settings = New-BaseSettings 0 0 0 $true
$settings.Commands = @(
@{ Name = "RunCmd"; ActionType = 3; Target = $cmdPath; IconGlyph = "D"; ColorIndex = 0 },
@{ Name = "RunPs1"; ActionType = 3; Target = $ps1Path; IconGlyph = "E"; ColorIndex = 1 }
)
Write-Settings $settings
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not show wheel"
$p0 = Get-SectorPoint 0 2
Click-At $p0.X $p0.Y
Start-Sleep -Milliseconds 1200
Assert (Test-Path $cmdMarker) "cmd script did not run"
Assert (-not (Get-WheelInfo)) "wheel not hidden after cmd execute"
Press-Hotkey; Start-Sleep -Milliseconds 500
Assert (Get-WheelInfo) "hotkey did not re-show wheel"
$p1 = Get-SectorPoint 1 2
Click-At $p1.X $p1.Y
Start-Sleep -Milliseconds 1200
Assert (Test-Path $ps1Marker) "ps1 script did not run"
Remove-Item $cmdMarker, $ps1Marker, $cmdPath, $ps1Path -ErrorAction SilentlyContinue
}
Run-Test "global switch off: wheel cannot be summoned" {
Write-Settings (New-BaseSettings 0 0 0 $false)
Start-App
Press-Hotkey; Start-Sleep -Milliseconds 600
Assert (-not (Get-WheelInfo)) "wheel summoned while global switch off"
}
} finally {
Stop-App
if ($settingsBackup) { Move-Item $settingsBackup $settingsFile -Force } else { Remove-Item $settingsFile -ErrorAction SilentlyContinue }
}
Write-Host ""
Write-Host ("=== E2E RESULT: {0}/{1} passed ===" -f ($results.Count - $failed), $results.Count)
exit $failed
+19
View File
@@ -0,0 +1,19 @@
Add-Type -AssemblyName System.Drawing
$bmp = New-Object System.Drawing.Bitmap(256, 256)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$colors = @('#E5484D', '#F76B15', '#F5A623', '#30A46C', '#12A594', '#0091FF', '#5E5CE6', '#BF5AF2')
$rect = New-Object System.Drawing.Rectangle(8, 8, 240, 240)
for ($i = 0; $i -lt 8; $i++) {
$brush = New-Object System.Drawing.SolidBrush([System.Drawing.ColorTranslator]::FromHtml($colors[$i]))
$g.FillPie($brush, $rect, ($i * 45 - 90), 46)
$brush.Dispose()
}
$white = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White)
$g.FillEllipse($white, 78, 78, 100, 100)
$white.Dispose()
$g.Dispose()
$out = Join-Path (Split-Path -Parent $PSScriptRoot) 'src/OneClickRun.App/Assets/AppIcon.png'
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host "icon saved to $out"
+17
View File
@@ -0,0 +1,17 @@
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class HKTest {
[DllImport("user32.dll", SetLastError=true)] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);
[DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
}
'@
$h = [HKTest]::GetConsoleWindow()
Write-Host "hwnd=$h"
foreach ($spec in @(@('Ctrl+Alt+Q', 3, 0x51), @('Ctrl+Alt+K', 3, 0x4B), @('Ctrl+Alt+W', 3, 0x57), @('Ctrl+Shift+Space', 6, 0x20))) {
$ok = [HKTest]::RegisterHotKey($h, 1, [uint32]$spec[1], [uint32]$spec[2])
$err = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
Write-Host ($spec[0] + ' => ' + $ok + ' err=' + $err)
if ($ok) { [void][HKTest]::UnregisterHotKey($h, 1) }
}
+85
View File
@@ -0,0 +1,85 @@
# 冒烟测试:启动应用 → 验证主窗口 → Ctrl+Alt+Q 呼出轮盘 → Esc 关闭轮盘 → 退出
param(
[string]$Exe = "src/OneClickRun.App/bin/x64/Debug/net8.0-windows10.0.19041.0/win-x64/OneClickRun.exe"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$exePath = Join-Path $root $Exe
if (-not (Test-Path $exePath)) { throw "找不到应用: $exePath" }
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class WinEnum {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
}
"@
$script:Wins = New-Object System.Collections.Generic.List[object]
$script:TargetPid = 0
$cb = [WinEnum+EnumWindowsProc]{
param($h, $l)
$wpid = 0
[void][WinEnum]::GetWindowThreadProcessId($h, [ref]$wpid)
if ($wpid -eq $script:TargetPid) {
$sb = New-Object System.Text.StringBuilder 256
[void][WinEnum]::GetWindowText($h, $sb, 256)
$script:Wins.Add([pscustomobject]@{ Visible = [WinEnum]::IsWindowVisible($h); Title = $sb.ToString() })
}
return $true
}
function Show-Windows {
$script:Wins.Clear()
[void][WinEnum]::EnumWindows($cb, [IntPtr]::Zero)
$script:Wins | ForEach-Object { Write-Host (" visible={0} title='{1}'" -f $_.Visible, $_.Title) }
}
$proc = Start-Process -FilePath $exePath -PassThru
$script:TargetPid = $proc.Id
try {
Write-Host "已启动 PID=$($proc.Id)"
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "进程提前退出,退出码 $($proc.ExitCode)" }
Write-Host "=== 启动后窗口 ==="
Show-Windows
if (-not ($script:Wins | Where-Object { $_.Title -like '*一键运行*' })) { throw "未找到主窗口" }
# 发送 Ctrl+Alt+Q
[WinEnum]::keybd_event(0x11, 0, 0, [UIntPtr]::Zero)
[WinEnum]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[WinEnum]::keybd_event(0x51, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 120
[WinEnum]::keybd_event(0x51, 0, 2, [UIntPtr]::Zero)
[WinEnum]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
[WinEnum]::keybd_event(0x11, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Seconds 1
Write-Host "=== 按 Ctrl+Alt+Q 后 ==="
Show-Windows
$wheel = $script:Wins | Where-Object { $_.Title -like '*Wheel*' }
if (-not $wheel) { throw "热键未能呼出轮盘窗口" }
if (-not $wheel.Visible) { throw "轮盘窗口存在但不可见" }
Write-Host "PASS: 轮盘已呼出"
# Esc 关闭
[WinEnum]::keybd_event(0x1B, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 80
[WinEnum]::keybd_event(0x1B, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 800
Write-Host "=== 按 Esc 后 ==="
Show-Windows
$wheel2 = $script:Wins | Where-Object { $_.Title -like '*Wheel*' }
if ($wheel2 -and $wheel2.Visible) { throw "Esc 后轮盘仍可见" }
Write-Host "PASS: 轮盘已关闭"
Write-Host "SMOKE OK"
}
finally {
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
}
+27
View File
@@ -0,0 +1,27 @@
<Application
x:Class="OneClickRun.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="using:OneClickRun.App.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="WheelCenterBrush" Color="#F2FFFFFF" />
<SolidColorBrush x:Key="WheelCenterBorderBrush" Color="#40000000" />
<SolidColorBrush x:Key="WheelCenterTextBrush" Color="#FF1F1F1F" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="WheelCenterBrush" Color="#F21F1F1F" />
<SolidColorBrush x:Key="WheelCenterBorderBrush" Color="#59FFFFFF" />
<SolidColorBrush x:Key="WheelCenterTextBrush" Color="#FFFFFFFF" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<conv:ColorIndexToBrushConverter x:Key="ColorIndexToBrush" />
<conv:ActionTypeToTextConverter x:Key="ActionTypeToText" />
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+114
View File
@@ -0,0 +1,114 @@
using Microsoft.UI.Xaml;
using OneClickRun.App.Services;
using OneClickRun.Core.Models;
using OneClickRun.Core.Services;
namespace OneClickRun.App;
public partial class App : Application
{
private Mutex? _singleInstanceMutex;
public static App Instance => (App)Current;
public AppLogger Log { get; private set; } = null!;
public SettingsStore Store { get; private set; } = null!;
public AppSettings Settings { get; private set; } = null!;
public HotkeyService Hotkeys { get; private set; } = null!;
public WheelController Wheel { get; private set; } = null!;
public StartupService Startup { get; private set; } = null!;
public TrayIconService Tray { get; private set; } = null!;
public MainWindow Main { get; private set; } = null!;
public App()
{
InitializeComponent();
UnhandledException += OnUnhandledException;
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_singleInstanceMutex = new Mutex(initiallyOwned: true, "OneClickRun.SingleInstance", out bool isNewInstance);
if (!isNewInstance)
{
_ = NativeMethods.MessageBox(IntPtr.Zero, "一键运行已在运行,请在系统托盘查找图标,或使用全局快捷键呼出轮盘。", "一键运行", 0x40);
Exit();
return;
}
Log = new AppLogger();
Log.Info("应用启动");
Store = new SettingsStore(SettingsStore.DefaultDirectory);
Settings = Store.Load();
Log.Info("设置加载完成");
Startup = new StartupService();
Hotkeys = new HotkeyService();
Hotkeys.Start();
Log.Info("快捷键服务启动完成");
Wheel = new WheelController(this);
Log.Info("轮盘控制器创建完成");
Main = new MainWindow(this);
Log.Info("主窗口创建完成");
Tray = new TrayIconService(this);
Log.Info("系统托盘图标创建完成");
Main.Activate();
Log.Info("主窗口已激活");
ApplySettingsToSystem();
Log.Info("设置已应用");
}
/// <summary>把内存中的设置应用到系统:主题、开机启动、快捷键、轮盘。</summary>
public void ApplySettingsToSystem()
{
ThemeService.Apply(Settings.ThemeMode, Main, Wheel.Window);
Startup.Apply(Settings.StartWithWindows);
Hotkeys.ApplySettings(Settings.WheelEnabled, Settings.Hotkey);
Wheel.RefreshSettings();
}
public void SaveSettings()
{
try
{
Store.Save(Settings);
}
catch (Exception ex)
{
LogError("保存设置失败", ex);
}
}
public void ReportError(string message)
{
Log.Info("报告错误:" + message);
Main?.ShowError(message);
}
/// <summary>退出整个应用:关闭托盘图标、轮盘与主窗口并结束进程。</summary>
public void ExitApplication()
{
try { Tray?.Dispose(); } catch (Exception ex) { LogError("销毁托盘图标失败", ex); }
try { Wheel?.Dispose(); } catch (Exception ex) { LogError("销毁轮盘失败", ex); }
try { Main?.CloseForExit(); } catch (Exception ex) { LogError("关闭主窗口失败", ex); }
try { Hotkeys?.Dispose(); } catch (Exception ex) { LogError("释放快捷键失败", ex); }
Exit();
}
public void LogInfo(string message) => Log.Info(message);
public void LogError(string context, Exception ex) => Log.Error(context, ex);
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
Log.Error("未处理异常", e.Exception);
e.Handled = true;
try
{
Main?.ShowError("发生未处理的错误:" + e.Message);
}
catch
{
// 忽略二次异常
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,24 @@
<UserControl
x:Class="OneClickRun.App.Controls.WheelView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Width="560" Height="560" Background="Transparent"
PointerMoved="OnPointerMoved"
PointerPressed="OnPointerPressed"
PointerReleased="OnPointerReleased"
PointerExited="OnPointerExited"
PointerCanceled="OnPointerExited">
<Canvas x:Name="LayerCanvas" />
<Ellipse Width="152" Height="152" HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{ThemeResource WheelCenterBrush}"
Stroke="{ThemeResource WheelCenterBorderBrush}"
StrokeThickness="1.5" />
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="4">
<TextBlock x:Name="CenterTitle" Text="一键运行" FontSize="17" FontWeight="SemiBold"
Foreground="{ThemeResource WheelCenterTextBrush}" HorizontalAlignment="Center" />
<TextBlock x:Name="CenterHint" Text="" FontSize="12" Opacity="0.65"
Foreground="{ThemeResource WheelCenterTextBrush}" HorizontalAlignment="Center"
MaxWidth="132" TextWrapping="Wrap" TextAlignment="Center" />
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,241 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Shapes;
using OneClickRun.App.Services;
using OneClickRun.Core.Data;
using OneClickRun.Core.Logic;
using OneClickRun.Core.Models;
using Windows.Foundation;
using Windows.UI;
using Path = Microsoft.UI.Xaml.Shapes.Path;
namespace OneClickRun.App.Controls;
/// <summary>
/// 扇区轮盘控件:Path 绘制扇形 + 图标 + 名称;悬停高亮、点击/双击/方位选择均由上层驱动。
/// 角度约定:0° 在 12 点钟方向,顺时针增大。
/// </summary>
public sealed partial class WheelView : UserControl
{
private const double Center = 280;
private const double OuterRadius = 278;
private const double InnerRadius = 76;
private static readonly FontFamily IconFontFamily = new("Segoe Fluent Icons, Segoe MDL2 Assets");
private IReadOnlyList<CommandItem> _items = Array.Empty<CommandItem>();
private readonly List<Path> _sectors = new();
private int _hoverIndex = -1;
private int _pendingIndex = -1;
private int _pressedIndex = -1;
public int Count => _items.Count;
public event Action<int>? SectorClicked;
/// <summary>点击轮盘外空白区域(由窗口层处理关闭)。</summary>
public event Action? DismissRequested;
public WheelView()
{
InitializeComponent();
}
public void SetItems(IReadOnlyList<CommandItem> items)
{
_items = items;
_hoverIndex = -1;
_pendingIndex = -1;
_pressedIndex = -1;
Rebuild();
}
public void SetHoverIndex(int index)
{
if (index == _hoverIndex) return;
_hoverIndex = index;
UpdateVisuals();
}
public void SetPendingIndex(int index)
{
_pendingIndex = index;
UpdateVisuals();
}
public void ClearPending() => SetPendingIndex(-1);
private void Rebuild()
{
LayerCanvas.Children.Clear();
_sectors.Clear();
if (_items.Count == 0)
{
CenterTitle.Text = "未配置指令";
CenterHint.Text = "请到设置中添加快捷指令";
return;
}
CenterTitle.Text = "一键运行";
CenterHint.Text = "";
double span = 360.0 / _items.Count;
for (int i = 0; i < _items.Count; i++)
{
var item = _items[i];
var sector = CreateSector(i, span);
LayerCanvas.Children.Add(sector);
_sectors.Add(sector);
double mid = (i + 0.5) * span;
var iconPos = WheelMath.PolarPoint(Center, Center, (InnerRadius + OuterRadius) / 2 - 16, mid);
var icon = new FontIcon
{
Glyph = item.IconGlyph,
FontFamily = IconFontFamily,
FontSize = 20,
Foreground = new SolidColorBrush(Color.FromArgb(255, 255, 255, 255)),
Width = 32,
Height = 32,
};
Canvas.SetLeft(icon, iconPos.X - 16);
Canvas.SetTop(icon, iconPos.Y - 16);
LayerCanvas.Children.Add(icon);
var textPos = WheelMath.PolarPoint(Center, Center, (InnerRadius + OuterRadius) / 2 + 24, mid);
var label = new TextBlock
{
Text = item.Name,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromArgb(255, 255, 255, 255)),
TextTrimming = TextTrimming.CharacterEllipsis,
TextAlignment = TextAlignment.Center,
MaxWidth = 110,
};
label.Measure(new Size(110, 40));
Canvas.SetLeft(label, textPos.X - label.DesiredSize.Width / 2);
Canvas.SetTop(label, textPos.Y - label.DesiredSize.Height / 2);
LayerCanvas.Children.Add(label);
}
UpdateVisuals();
}
private Path CreateSector(int index, double span)
{
var path = new Path
{
Stroke = SeparatorBrush,
StrokeThickness = 1.5,
};
if (_items.Count == 1)
{
path.Data = new EllipseGeometry { Center = new Point(Center, Center), RadiusX = OuterRadius, RadiusY = OuterRadius };
return path;
}
double start = index * span;
double end = (index + 1) * span;
var outerStart = ToPoint(WheelMath.PolarPoint(Center, Center, OuterRadius, start));
var outerEnd = ToPoint(WheelMath.PolarPoint(Center, Center, OuterRadius, end));
var innerEnd = ToPoint(WheelMath.PolarPoint(Center, Center, InnerRadius, end));
var innerStart = ToPoint(WheelMath.PolarPoint(Center, Center, InnerRadius, start));
var figure = new PathFigure { StartPoint = outerStart, IsClosed = true };
figure.Segments.Add(new ArcSegment
{
Point = outerEnd,
Size = new Size(OuterRadius, OuterRadius),
IsLargeArc = span > 180,
SweepDirection = SweepDirection.Clockwise,
});
figure.Segments.Add(new LineSegment { Point = innerEnd });
figure.Segments.Add(new ArcSegment
{
Point = innerStart,
Size = new Size(InnerRadius, InnerRadius),
IsLargeArc = span > 180,
SweepDirection = SweepDirection.Counterclockwise,
});
var geometry = new PathGeometry();
geometry.Figures.Add(figure);
path.Data = geometry;
return path;
}
private static Point ToPoint((double X, double Y) p) => new(p.X, p.Y);
private SolidColorBrush SeparatorBrush => ActualTheme == ElementTheme.Dark
? new SolidColorBrush(Color.FromArgb(0x59, 255, 255, 255))
: new SolidColorBrush(Color.FromArgb(0x59, 0, 0, 0));
private void UpdateVisuals()
{
for (int i = 0; i < _sectors.Count; i++)
{
var color = ColorHelper.FromHex(WheelPalette.Get(_items[i].ColorIndex));
bool active = i == _hoverIndex || i == _pendingIndex;
byte alpha = active ? (byte)0xEE : (byte)0xB8;
_sectors[i].Fill = new SolidColorBrush(Color.FromArgb(alpha, color.R, color.G, color.B));
_sectors[i].StrokeThickness = i == _pendingIndex ? 3 : i == _hoverIndex ? 2.5 : 1.5;
}
CenterHint.Text = _hoverIndex >= 0 && _hoverIndex < _items.Count ? _items[_hoverIndex].Name : "";
}
private void OnPointerMoved(object sender, PointerRoutedEventArgs e)
{
if (TryGetSector(e, out int index)) SetHoverIndex(index);
}
private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
{
_pressedIndex = TryGetSector(e, out int index) ? index : -1;
}
private void OnPointerReleased(object sender, PointerRoutedEventArgs e)
{
bool inSector = TryGetSector(e, out int index);
if (inSector && index == _pressedIndex && index >= 0)
{
SectorClicked?.Invoke(index);
}
else if (!inSector && _pressedIndex < 0 && IsOutsideWheel(e))
{
DismissRequested?.Invoke();
}
_pressedIndex = -1;
}
private void OnPointerExited(object sender, PointerRoutedEventArgs e)
{
if (_pressedIndex < 0) SetHoverIndex(-1);
}
private bool TryGetSector(PointerRoutedEventArgs e, out int index)
{
index = -1;
if (_items.Count == 0) return false;
var p = e.GetCurrentPoint(this).Position;
double dx = p.X - Center;
double dy = p.Y - Center;
double dist = Math.Sqrt(dx * dx + dy * dy);
if (dist < InnerRadius || dist > OuterRadius + 4) return false;
index = WheelMath.GetSectorIndex(_items.Count, WheelMath.AngleFromVector(dx, dy));
return true;
}
private bool IsOutsideWheel(PointerRoutedEventArgs e)
{
if (_items.Count == 0) return true;
var p = e.GetCurrentPoint(this).Position;
double dx = p.X - Center;
double dy = p.Y - Center;
return Math.Sqrt(dx * dx + dy * dy) > OuterRadius + 12;
}
}
@@ -0,0 +1,39 @@
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
using OneClickRun.App.Services;
using OneClickRun.Core.Data;
using OneClickRun.Core.Models;
namespace OneClickRun.App.Converters;
/// <summary>调色板索引 → 画刷(列表里的图标颜色点)。</summary>
public sealed class ColorIndexToBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
int index = value is int i ? i : 0;
return new SolidColorBrush(ColorHelper.FromHex(WheelPalette.Get(index)));
}
public object ConvertBack(object value, Type targetType, object parameter, string language) => 0;
}
/// <summary>指令类型 → 中文标签。</summary>
public sealed class ActionTypeToTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
return value is ActionType t
? t switch
{
ActionType.App => "软件",
ActionType.Folder => "文件夹",
ActionType.Url => "网址",
ActionType.Script => "脚本",
_ => "",
}
: "";
}
public object ConvertBack(object value, Type targetType, object parameter, string language) => ActionType.App;
}
+44
View File
@@ -0,0 +1,44 @@
<Window
x:Class="OneClickRun.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<NavigationView x:Name="Nav"
PaneDisplayMode="Left"
OpenPaneLength="208"
IsBackButtonVisible="Collapsed"
IsSettingsVisible="False"
SelectionChanged="Nav_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem Content="常规" Tag="general">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE80F;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="呼出与快捷键" Tag="behavior">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE765;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="快捷指令" Tag="commands">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE71D;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="关于" Tag="about">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE946;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
</NavigationView.MenuItems>
<ContentControl x:Name="PageHost" />
</NavigationView>
<InfoBar x:Name="ErrorBar"
IsOpen="False"
Severity="Error"
IsClosable="True"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Margin="220,0,24,20" />
</Grid>
</Window>
+95
View File
@@ -0,0 +1,95 @@
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using OneClickRun.App.Pages;
using Windows.Graphics;
namespace OneClickRun.App;
/// <summary>设置主窗口:NavigationView 四页 + 底部错误提示条。</summary>
public sealed partial class MainWindow : Window
{
private readonly App _app;
private bool _allowClose;
private readonly GeneralPage _generalPage;
private readonly BehaviorPage _behaviorPage;
private readonly CommandsPage _commandsPage;
private readonly AboutPage _aboutPage;
public MainWindow(App app)
{
_app = app;
InitializeComponent();
Title = "一键运行 - 设置";
SystemBackdrop = new MicaBackdrop();
AppWindow.Resize(new SizeInt32(1040, 720));
try
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.png");
if (File.Exists(iconPath)) AppWindow.SetIcon(iconPath);
}
catch
{
// 图标缺失不影响运行
}
_generalPage = new GeneralPage();
_behaviorPage = new BehaviorPage();
_commandsPage = new CommandsPage();
_aboutPage = new AboutPage();
Nav.SelectedItem = Nav.MenuItems[0];
PageHost.Content = _generalPage;
AppWindow.Closing += OnAppWindowClosing;
Closed += (_, _) => _app.Hotkeys.Dispose();
}
private void Nav_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag)
{
PageHost.Content = tag switch
{
"general" => _generalPage,
"behavior" => _behaviorPage,
"commands" => _commandsPage,
"about" => _aboutPage,
_ => PageHost.Content,
};
}
}
/// <summary>点击关闭按钮时取消关闭并最小化到系统托盘。</summary>
private void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (_allowClose) return;
args.Cancel = true;
AppWindow.Hide();
_app.LogInfo("设置窗口已最小化到系统托盘");
}
/// <summary>从系统托盘恢复并激活设置窗口。</summary>
public void ShowSettings()
{
if (!AppWindow.IsVisible) AppWindow.Show();
Activate();
}
/// <summary>真正关闭主窗口(退出应用时由 App 调用)。</summary>
public void CloseForExit()
{
_allowClose = true;
Close();
}
/// <summary>在设置窗口底部弹出错误提示(必要时先激活窗口)。</summary>
public void ShowError(string message)
{
ErrorBar.Message = message;
ErrorBar.IsOpen = true;
if (!AppWindow.IsVisible) AppWindow.Show();
Activate();
}
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>OneClickRun.App</RootNamespace>
<AssemblyName>OneClickRun</AssemblyName>
<UseWinUI>true</UseWinUI>
<!-- 仅引用 WinForms 程序集用于系统托盘图标,不引入 WindowsDesktop 构建目标(避免 WPF XAML 编译器与 WinUI XAML 冲突) -->
<UseWindowsForms>true</UseWindowsForms>
<ImportWindowsDesktopTargets>false</ImportWindowsDesktopTargets>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Platforms>x64;x86;ARM64</Platforms>
<RuntimeIdentifiers>win-x64;win-x86;win-arm64</RuntimeIdentifiers>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
</ItemGroup>
<!-- WinForms 的隐式全局 using 会与 WinUI 类型冲突(Application/UserControl/Point/Color 等),仅保留显式引用 -->
<ItemGroup>
<Using Remove="System.Drawing" />
<Using Remove="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.4.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4948" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OneClickRun.Core\OneClickRun.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
<Page
x:Class="OneClickRun.App.Pages.AboutPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Padding="28" Spacing="8" MaxWidth="760" HorizontalAlignment="Left">
<TextBlock Text="关于" Style="{StaticResource TitleTextBlockStyle}" />
<TextBlock Text="一键运行(OneClickRun)—— 游戏轮盘式快捷启动工具" FontWeight="SemiBold" Margin="0,4,0,0" />
<TextBlock x:Name="VersionText" Opacity="0.6" />
<TextBlock Text="功能" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,20,0,0" />
<TextBlock TextWrapping="Wrap" Opacity="0.85">
以全局快捷键呼出圆形扇区轮盘,快速打开软件、文件夹、网址或运行 PowerShell 脚本。
支持点击显示 / 长按显示 / 按住显示三种呼出方式,单击 / 双击 / 滑动三种选择方式。
</TextBlock>
<TextBlock Text="默认快捷键" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,20,0,0" />
<TextBlock Text="Ctrl+Alt+Q(可在“呼出与快捷键”页修改);轮盘显示时按 Esc 关闭。" Opacity="0.85" />
<TextBlock Text="数据与日志" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,20,0,0" />
<TextBlock TextWrapping="Wrap" Opacity="0.85" x:Name="DataDirText" />
<Button Content="打开数据文件夹" Click="OpenDataFolder_Click" HorizontalAlignment="Left" Margin="0,10,0,0" />
<TextBlock Text="提示" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,20,0,0" />
<TextBlock TextWrapping="Wrap" Opacity="0.85">
关闭此窗口会退出程序(快捷键随之失效);如需常驻,请最小化窗口或开启“开机启动”。
</TextBlock>
</StackPanel>
</ScrollViewer>
</Page>
@@ -0,0 +1,37 @@
using System.Diagnostics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OneClickRun.Core.Services;
namespace OneClickRun.App.Pages;
/// <summary>关于页:版本、默认快捷键、数据目录。</summary>
public sealed partial class AboutPage : Page
{
public AboutPage()
{
InitializeComponent();
var v = typeof(App).Assembly.GetName().Version;
VersionText.Text = "版本 " + (v == null ? "0.1.0" : v.ToString(3));
DataDirText.Text = SettingsStore.DefaultDirectory + "\\settings.json(日志位于同目录 logs 子文件夹)";
}
private void OpenDataFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var dir = SettingsStore.DefaultDirectory;
Directory.CreateDirectory(dir);
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = "\"" + dir + "\"",
UseShellExecute = false,
});
}
catch (Exception ex)
{
App.Instance.ReportError("打开数据文件夹失败:" + ex.Message);
}
}
}
@@ -0,0 +1,54 @@
<Page
x:Class="OneClickRun.App.Pages.BehaviorPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel x:Name="Root" Padding="28" Spacing="8" MaxWidth="760" HorizontalAlignment="Left">
<TextBlock Text="呼出与快捷键" Style="{StaticResource TitleTextBlockStyle}" />
<TextBlock Text="轮盘显示位置、呼出方式与指令选择方式" Opacity="0.6" />
<TextBlock Text="轮盘显示位置" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,16,0,0" />
<StackPanel Spacing="2">
<RadioButton x:Name="PosCenter" GroupName="pos" Content="屏幕中心" Checked="Position_Checked" />
<RadioButton x:Name="PosMouse" GroupName="pos" Content="鼠标位置" Checked="Position_Checked" />
</StackPanel>
<TextBlock Text="呼出方式" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,16,0,0" />
<StackPanel Spacing="2">
<RadioButton x:Name="SummonClick" GroupName="summon" Content="点击显示:按一次快捷键显示轮盘" Checked="Summon_Checked" />
<RadioButton x:Name="SummonLongPress" GroupName="summon" Content="长按显示:按住快捷键一段时间后显示" Checked="Summon_Checked" />
<RadioButton x:Name="SummonHold" GroupName="summon" Content="按住显示:按住时显示,松开即隐藏" Checked="Summon_Checked" />
</StackPanel>
<StackPanel x:Name="LongPressPanel" Spacing="4" Margin="0,8,0,0" Visibility="Collapsed">
<TextBlock Text="长按时长" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Slider x:Name="LongPressSlider"
ValueChanged="LongPressSlider_ValueChanged" PointerReleased="LongPressSlider_PointerReleased" />
<TextBlock x:Name="LongPressValue" Grid.Column="1" VerticalAlignment="Center" Margin="16,0,0,0" MinWidth="72" />
</Grid>
</StackPanel>
<TextBlock Text="指令选择方式" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,16,0,0" />
<StackPanel Spacing="2">
<RadioButton x:Name="SelSingle" GroupName="sel" Content="单击选择" Checked="Selection_Checked" />
<RadioButton x:Name="SelDouble" GroupName="sel" Content="双击选择" Checked="Selection_Checked" />
<RadioButton x:Name="SelSwipe" GroupName="sel" Content="滑动选择(松开快捷键时按鼠标方位选择)" Checked="Selection_Checked" />
</StackPanel>
<TextBlock x:Name="SwipeHint" Text="提示:滑动选择仅“按住显示”有效,已自动切换到按住显示。"
Opacity="0.55" Visibility="Collapsed" TextWrapping="Wrap" />
<TextBlock Text="全局快捷键" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,16,0,0" />
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBox x:Name="HotkeyBox" IsReadOnly="True" Width="170" VerticalAlignment="Center" />
<Button x:Name="CaptureButton" Content="修改快捷键" Click="CaptureButton_Click" />
<Button Content="预览轮盘" Click="PreviewButton_Click" />
</StackPanel>
<TextBlock x:Name="CaptureHint" Text="" Opacity="0.55" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</Page>
@@ -0,0 +1,209 @@
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using OneClickRun.Core.Logic;
using OneClickRun.Core.Models;
using Windows.System;
using Windows.UI.Core;
using SelectionMode = OneClickRun.Core.Models.SelectionMode;
namespace OneClickRun.App.Pages;
/// <summary>呼出与快捷键:显示位置、呼出方式、选择方式、全局快捷键录制。</summary>
public sealed partial class BehaviorPage : Page
{
private bool _loading;
private bool _capturing;
public BehaviorPage()
{
InitializeComponent();
// WinUI3 RangeBase 对取值顺序有校验(Value 必须始终落在 [Minimum, Maximum] 内),
// XAML 属性赋值顺序不可控,因此按安全顺序在代码中设置。
// 设置 Value 会触发 ValueChanged,必须用 _loading 屏蔽,避免覆盖已保存的长按时长。
_loading = true;
LongPressSlider.Maximum = 1500;
LongPressSlider.Value = 200;
LongPressSlider.Minimum = 200;
LongPressSlider.StepFrequency = 100;
_loading = false;
Root.KeyDown += Page_KeyDown;
Loaded += (_, _) => LoadFromSettings();
}
private void LoadFromSettings()
{
_loading = true;
var s = App.Instance.Settings;
PosCenter.IsChecked = s.WheelPosition == WheelPositionMode.Center;
PosMouse.IsChecked = s.WheelPosition == WheelPositionMode.Mouse;
SummonClick.IsChecked = s.SummonMode == SummonMode.Click;
SummonLongPress.IsChecked = s.SummonMode == SummonMode.LongPress;
SummonHold.IsChecked = s.SummonMode == SummonMode.Hold;
LongPressSlider.Value = s.LongPressDelayMs;
LongPressValue.Text = s.LongPressDelayMs + " ms";
SelSingle.IsChecked = s.SelectionMode == SelectionMode.SingleClick;
SelDouble.IsChecked = s.SelectionMode == SelectionMode.DoubleClick;
SelSwipe.IsChecked = s.SelectionMode == SelectionMode.Swipe;
HotkeyBox.Text = HotkeyFormat.Format(s.Hotkey);
_loading = false;
UpdateDerivedVisibility();
}
private void Position_Checked(object sender, RoutedEventArgs e)
{
if (_loading) return;
App.Instance.Settings.WheelPosition = PosMouse.IsChecked == true ? WheelPositionMode.Mouse : WheelPositionMode.Center;
SaveAndApply();
}
private void Summon_Checked(object sender, RoutedEventArgs e)
{
if (_loading) return;
var s = App.Instance.Settings;
s.SummonMode = SummonHold.IsChecked == true ? SummonMode.Hold
: SummonLongPress.IsChecked == true ? SummonMode.LongPress
: SummonMode.Click;
// 滑动选择仅在按住显示时有效
if (s.SummonMode != SummonMode.Hold && s.SelectionMode == SelectionMode.Swipe)
{
s.SelectionMode = SelectionMode.SingleClick;
_loading = true;
SelSingle.IsChecked = true;
_loading = false;
}
UpdateDerivedVisibility();
SaveAndApply();
}
private void Selection_Checked(object sender, RoutedEventArgs e)
{
if (_loading) return;
var s = App.Instance.Settings;
s.SelectionMode = SelSwipe.IsChecked == true ? SelectionMode.Swipe
: SelDouble.IsChecked == true ? SelectionMode.DoubleClick
: SelectionMode.SingleClick;
// 滑动选择要求按住显示
if (s.SelectionMode == SelectionMode.Swipe && s.SummonMode != SummonMode.Hold)
{
s.SummonMode = SummonMode.Hold;
_loading = true;
SummonHold.IsChecked = true;
_loading = false;
}
UpdateDerivedVisibility();
SaveAndApply();
}
private void UpdateDerivedVisibility()
{
var s = App.Instance.Settings;
LongPressPanel.Visibility = s.SummonMode == SummonMode.LongPress ? Visibility.Visible : Visibility.Collapsed;
SelSwipe.IsEnabled = s.SummonMode == SummonMode.Hold;
SwipeHint.Visibility = s.SelectionMode == SelectionMode.Swipe ? Visibility.Visible : Visibility.Collapsed;
}
private void LongPressSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
{
if (_loading) return;
int ms = (int)Math.Round(LongPressSlider.Value);
App.Instance.Settings.LongPressDelayMs = ms;
LongPressValue.Text = ms + " ms";
}
private void LongPressSlider_PointerReleased(object sender, PointerRoutedEventArgs e) => SaveAndApply();
private void CaptureButton_Click(object sender, RoutedEventArgs e)
{
if (_capturing) return;
_capturing = true;
App.Instance.Hotkeys.Suspend();
CaptureButton.Content = "请按下新快捷键…(Esc 取消)";
CaptureHint.Text = "请按下组合键,例如 Ctrl+Alt+Q;需要包含 Ctrl、Alt 或 Win 键。";
HotkeyBox.Text = "";
CaptureButton.Focus(FocusState.Programmatic);
}
private void Page_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (!_capturing) return;
var key = e.Key;
if (key == VirtualKey.Escape)
{
FinishCapture(cancelled: true);
e.Handled = true;
return;
}
if (IsModifierKey(key)) return;
var mods = ReadCurrentModifiers();
if ((mods & (HotkeyModifiers.Control | HotkeyModifiers.Alt | HotkeyModifiers.Win)) == 0)
{
CaptureHint.Text = "需要包含 Ctrl、Alt 或 Win 键,请重新按下组合键。";
return;
}
FinishCapture(cancelled: false, new HotkeyDefinition { Modifiers = mods, Key = (int)key });
e.Handled = true;
}
private void FinishCapture(bool cancelled, HotkeyDefinition? definition = null)
{
_capturing = false;
CaptureButton.Content = "修改快捷键";
var app = App.Instance;
app.Hotkeys.Resume();
if (cancelled || definition == null)
{
HotkeyBox.Text = HotkeyFormat.Format(app.Settings.Hotkey);
CaptureHint.Text = "";
return;
}
if (app.Hotkeys.TrySetHotkey(definition))
{
app.Settings.Hotkey = definition;
app.SaveSettings();
HotkeyBox.Text = HotkeyFormat.Format(definition);
CaptureHint.Text = "快捷键已更新。";
}
else
{
app.Hotkeys.ApplySettings(app.Settings.WheelEnabled, app.Settings.Hotkey);
HotkeyBox.Text = HotkeyFormat.Format(app.Settings.Hotkey);
CaptureHint.Text = "该快捷键注册失败(可能已被系统或其他程序占用),已保留原快捷键。";
}
}
private void PreviewButton_Click(object sender, RoutedEventArgs e) => App.Instance.Wheel.Preview();
private static bool IsModifierKey(VirtualKey key) =>
key == VirtualKey.Control || key == VirtualKey.Menu ||
key == VirtualKey.Shift || key == VirtualKey.LeftWindows || key == VirtualKey.RightWindows;
private static HotkeyModifiers ReadCurrentModifiers()
{
var mods = HotkeyModifiers.None;
if (IsDown(VirtualKey.Control)) mods |= HotkeyModifiers.Control;
if (IsDown(VirtualKey.Menu)) mods |= HotkeyModifiers.Alt;
if (IsDown(VirtualKey.Shift)) mods |= HotkeyModifiers.Shift;
if (IsDown(VirtualKey.LeftWindows) || IsDown(VirtualKey.RightWindows)) mods |= HotkeyModifiers.Win;
return mods;
}
private static bool IsDown(VirtualKey key)
{
return (InputKeyboardSource.GetKeyStateForCurrentThread(key) & CoreVirtualKeyStates.Down) != 0;
}
private static void SaveAndApply()
{
var app = App.Instance;
app.SaveSettings();
app.ApplySettingsToSystem();
}
}
@@ -0,0 +1,55 @@
<Page
x:Class="OneClickRun.App.Pages.CommandsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Padding="28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="快捷指令" Style="{StaticResource TitleTextBlockStyle}" />
<TextBlock Grid.Row="1" Text="配置轮盘上每个扇区的指令(最多 12 个),双击条目可直接编辑。"
Opacity="0.6" Margin="0,4,0,12" />
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ListView x:Name="CommandList" SelectionMode="Single"
DoubleTapped="CommandList_DoubleTapped">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Padding="4,8" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon Glyph="{Binding IconGlyph}" FontSize="18"
Foreground="{Binding ColorIndex, Converter={StaticResource ColorIndexToBrush}}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Target}" FontSize="12" Opacity="0.6"
TextTrimming="CharacterEllipsis" MaxWidth="560" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding ActionType, Converter={StaticResource ActionTypeToText}}"
FontSize="12" Opacity="0.7" VerticalAlignment="Center" Margin="12,0,0,0" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel Grid.Column="1" Spacing="8" Margin="16,0,0,0" VerticalAlignment="Top">
<Button Content="新增指令" Click="Add_Click" HorizontalAlignment="Stretch" />
<Button Content="编辑" Click="Edit_Click" HorizontalAlignment="Stretch" />
<Button Content="删除" Click="Delete_Click" HorizontalAlignment="Stretch" />
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" Margin="0,4" />
<Button Content="上移" Click="MoveUp_Click" HorizontalAlignment="Stretch" />
<Button Content="下移" Click="MoveDown_Click" HorizontalAlignment="Stretch" />
</StackPanel>
</Grid>
<TextBlock Grid.Row="3" x:Name="CountText" Opacity="0.55" Margin="0,10,0,0" />
</Grid>
</Page>
@@ -0,0 +1,341 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using OneClickRun.App.Services;
using OneClickRun.Core.Data;
using OneClickRun.Core.Models;
using OneClickRun.Core.Services;
using Windows.Storage.Pickers;
namespace OneClickRun.App.Pages;
/// <summary>快捷指令列表:增删、排序、编辑器(名称/类型/目标/参数/图标/颜色/测试运行)。</summary>
public sealed partial class CommandsPage : Page
{
public CommandsPage()
{
InitializeComponent();
Loaded += (_, _) => RefreshList();
}
private static List<CommandItem> Items => App.Instance.Settings.Commands;
private void RefreshList()
{
var items = Items.ToList();
CommandList.ItemsSource = items;
CountText.Text = "共 " + items.Count + " 个指令(轮盘扇区数量与指令数量一致,上限 " + AppSettings.MaxCommands + " 个)";
}
private int SelectedIndex => CommandList.SelectedIndex;
private async void Add_Click(object sender, RoutedEventArgs e)
{
if (Items.Count >= AppSettings.MaxCommands)
{
App.Instance.ReportError("最多支持 " + AppSettings.MaxCommands + " 个指令,请先删除部分指令。");
return;
}
var item = new CommandItem { IconGlyph = GlyphCatalog.DefaultGlyphFor(ActionType.App) };
if (await EditItemAsync(item, isNew: true))
{
Items.Add(item);
App.Instance.SaveSettings();
App.Instance.ApplySettingsToSystem();
RefreshList();
CommandList.SelectedIndex = Items.Count - 1;
}
}
private async void Edit_Click(object sender, RoutedEventArgs e)
{
if (SelectedIndex < 0)
{
App.Instance.ReportError("请先在列表中选择一个指令。");
return;
}
var original = Items[SelectedIndex];
var copy = new CommandItem
{
Id = original.Id,
Name = original.Name,
ActionType = original.ActionType,
Target = original.Target,
Arguments = original.Arguments,
IconGlyph = original.IconGlyph,
ColorIndex = original.ColorIndex,
RunAsAdmin = original.RunAsAdmin,
HideWindow = original.HideWindow,
};
if (await EditItemAsync(copy, isNew: false))
{
Items[SelectedIndex] = copy;
App.Instance.SaveSettings();
App.Instance.ApplySettingsToSystem();
RefreshList();
}
}
private async void Delete_Click(object sender, RoutedEventArgs e)
{
if (SelectedIndex < 0)
{
App.Instance.ReportError("请先在列表中选择一个指令。");
return;
}
var item = Items[SelectedIndex];
var dialog = new ContentDialog
{
Title = "删除指令",
Content = "确定删除指令「" + item.Name + "」吗?",
PrimaryButtonText = "删除",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = XamlRoot,
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Items.RemoveAt(SelectedIndex);
App.Instance.SaveSettings();
App.Instance.ApplySettingsToSystem();
RefreshList();
}
}
private void MoveUp_Click(object sender, RoutedEventArgs e)
{
int i = SelectedIndex;
if (i <= 0) return;
(Items[i], Items[i - 1]) = (Items[i - 1], Items[i]);
App.Instance.SaveSettings();
App.Instance.ApplySettingsToSystem();
RefreshList();
CommandList.SelectedIndex = i - 1;
}
private void MoveDown_Click(object sender, RoutedEventArgs e)
{
int i = SelectedIndex;
if (i < 0 || i >= Items.Count - 1) return;
(Items[i], Items[i + 1]) = (Items[i + 1], Items[i]);
App.Instance.SaveSettings();
App.Instance.ApplySettingsToSystem();
RefreshList();
CommandList.SelectedIndex = i + 1;
}
private void CommandList_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e)
{
if (SelectedIndex >= 0) Edit_Click(sender, e);
}
/// <summary>打开指令编辑器;返回 true 表示用户保存。</summary>
private async Task<bool> EditItemAsync(CommandItem item, bool isNew)
{
var dialog = new ContentDialog
{
Title = isNew ? "新增指令" : "编辑指令",
PrimaryButtonText = "保存",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = XamlRoot,
};
var nameBox = new TextBox { Header = "名称", Text = item.Name, PlaceholderText = "例如:记事本" };
var typeBox = new ComboBox { Header = "类型", HorizontalAlignment = HorizontalAlignment.Stretch };
typeBox.Items.Add(new ComboBoxItem { Content = "打开软件" });
typeBox.Items.Add(new ComboBoxItem { Content = "打开文件夹" });
typeBox.Items.Add(new ComboBoxItem { Content = "打开网址" });
typeBox.Items.Add(new ComboBoxItem { Content = "运行 PowerShell 脚本" });
typeBox.SelectedIndex = (int)item.ActionType;
var targetBox = new TextBox { Header = "目标", Text = item.Target };
var browseButton = new Button { Content = "浏览…", Margin = new Thickness(8, 26, 0, 0) };
var targetGrid = new Grid();
targetGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
targetGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(targetBox, 0);
Grid.SetColumn(browseButton, 1);
targetGrid.Children.Add(targetBox);
targetGrid.Children.Add(browseButton);
var argsBox = new TextBox { Header = "参数(可选)", Text = item.Arguments ?? "" };
var adminBox = new CheckBox { Content = "以管理员身份运行", IsChecked = item.RunAsAdmin };
var hideBox = new CheckBox { Content = "隐藏窗口运行", IsChecked = item.HideWindow };
bool glyphAuto = true;
var glyphBox = new ComboBox { Header = "图标", HorizontalAlignment = HorizontalAlignment.Stretch, MinWidth = 200 };
foreach (var option in GlyphCatalog.All)
{
var icon = new FontIcon { Glyph = option.Glyph, FontSize = 16 };
var text = new TextBlock { Text = option.Name, Margin = new Thickness(8, 0, 0, 0) };
var optionPanel = new StackPanel { Orientation = Orientation.Horizontal };
optionPanel.Children.Add(icon);
optionPanel.Children.Add(text);
glyphBox.Items.Add(new ComboBoxItem { Content = optionPanel, Tag = option.Glyph });
}
int glyphIndex = GlyphCatalog.IndexOf(item.IconGlyph);
if (glyphIndex < 0)
{
glyphBox.Items.Add(new ComboBoxItem { Content = new TextBlock { Text = "自定义" }, Tag = item.IconGlyph });
glyphIndex = glyphBox.Items.Count - 1;
}
glyphBox.SelectedIndex = glyphIndex;
glyphBox.SelectionChanged += (_, _) => glyphAuto = false;
var colorBox = new ComboBox { Header = "颜色", HorizontalAlignment = HorizontalAlignment.Stretch, MinWidth = 200 };
for (int i = 0; i < WheelPalette.Colors.Length; i++)
{
var swatch = new Border
{
Background = new SolidColorBrush(ColorHelper.FromHex(WheelPalette.Colors[i])),
Width = 64,
Height = 14,
CornerRadius = new CornerRadius(3),
};
var swatchPanel = new StackPanel { Orientation = Orientation.Horizontal };
swatchPanel.Children.Add(swatch);
swatchPanel.Children.Add(new TextBlock { Text = "颜色 " + (i + 1), Margin = new Thickness(8, 0, 0, 0) });
colorBox.Items.Add(new ComboBoxItem { Content = swatchPanel, Tag = i });
}
colorBox.SelectedIndex = Math.Clamp(item.ColorIndex, 0, WheelPalette.Colors.Length - 1);
var warnText = new TextBlock
{
Foreground = new SolidColorBrush(Windows.UI.Color.FromArgb(255, 230, 72, 72)),
TextWrapping = TextWrapping.Wrap,
Visibility = Visibility.Collapsed,
};
var testButton = new Button { Content = "测试运行", HorizontalAlignment = HorizontalAlignment.Left };
var panel = new StackPanel { Spacing = 12, MinWidth = 440 };
panel.Children.Add(nameBox);
panel.Children.Add(typeBox);
panel.Children.Add(targetGrid);
panel.Children.Add(argsBox);
panel.Children.Add(adminBox);
panel.Children.Add(hideBox);
panel.Children.Add(glyphBox);
panel.Children.Add(colorBox);
panel.Children.Add(warnText);
panel.Children.Add(testButton);
dialog.Content = panel;
void UpdateVisibility()
{
var type = (ActionType)typeBox.SelectedIndex;
argsBox.Visibility = type == ActionType.App ? Visibility.Visible : Visibility.Collapsed;
adminBox.Visibility = type is ActionType.App or ActionType.Script ? Visibility.Visible : Visibility.Collapsed;
hideBox.Visibility = type == ActionType.Script ? Visibility.Visible : Visibility.Collapsed;
browseButton.Visibility = type == ActionType.Url ? Visibility.Collapsed : Visibility.Visible;
targetBox.Header = type switch
{
ActionType.App => "程序路径",
ActionType.Folder => "文件夹路径",
ActionType.Url => "网址(无协议时自动补 https://",
ActionType.Script => "脚本文件(.ps1",
_ => "目标",
};
if (glyphAuto)
{
var glyph = GlyphCatalog.DefaultGlyphFor(type);
int idx = GlyphCatalog.IndexOf(glyph);
if (idx >= 0) glyphBox.SelectedIndex = idx;
}
}
typeBox.SelectionChanged += (_, _) => UpdateVisibility();
UpdateVisibility();
browseButton.Click += async (_, _) =>
{
var type = (ActionType)typeBox.SelectedIndex;
if (type == ActionType.Folder)
{
var picker = new FolderPicker();
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(App.Instance.Main));
var folder = await picker.PickSingleFolderAsync();
if (folder != null) targetBox.Text = folder.Path;
}
else if (type is ActionType.App or ActionType.Script)
{
var picker = new FileOpenPicker();
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(App.Instance.Main));
if (type == ActionType.Script)
{
picker.FileTypeFilter.Add(".ps1");
picker.FileTypeFilter.Add(".cmd");
picker.FileTypeFilter.Add(".bat");
}
else
{
picker.FileTypeFilter.Add(".exe");
picker.FileTypeFilter.Add(".lnk");
picker.FileTypeFilter.Add("*");
}
var file = await picker.PickSingleFileAsync();
if (file != null) targetBox.Text = file.Path;
}
};
CommandItem BuildFromInputs()
{
return new CommandItem
{
Name = nameBox.Text.Trim(),
ActionType = (ActionType)typeBox.SelectedIndex,
Target = targetBox.Text.Trim(),
Arguments = argsBox.Text.Trim(),
IconGlyph = (glyphBox.SelectedItem as ComboBoxItem)?.Tag as string ?? "\uE756",
ColorIndex = (colorBox.SelectedItem as ComboBoxItem)?.Tag is int c ? c : 0,
RunAsAdmin = adminBox.IsChecked == true,
HideWindow = hideBox.IsChecked == true,
};
}
testButton.Click += (_, _) =>
{
var probe = BuildFromInputs();
var error = CommandRunner.Validate(probe);
if (error != null)
{
warnText.Text = error;
warnText.Visibility = Visibility.Visible;
return;
}
try
{
CommandRunner.Run(probe);
warnText.Text = "已启动,请检查目标是否按预期打开。";
warnText.Visibility = Visibility.Visible;
}
catch (Exception ex)
{
warnText.Text = "启动失败:" + ex.Message;
warnText.Visibility = Visibility.Visible;
}
};
dialog.PrimaryButtonClick += (_, args) =>
{
var probe = BuildFromInputs();
var error = CommandRunner.Validate(probe);
if (error != null)
{
warnText.Text = error;
warnText.Visibility = Visibility.Visible;
args.Cancel = true;
return;
}
item.Name = probe.Name;
item.ActionType = probe.ActionType;
item.Target = probe.Target;
item.Arguments = probe.Arguments;
item.IconGlyph = probe.IconGlyph;
item.ColorIndex = probe.ColorIndex;
item.RunAsAdmin = probe.RunAsAdmin;
item.HideWindow = probe.HideWindow;
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
}
@@ -0,0 +1,30 @@
<Page
x:Class="OneClickRun.App.Pages.GeneralPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Padding="28" Spacing="8" MaxWidth="760" HorizontalAlignment="Left">
<TextBlock Text="常规" Style="{StaticResource TitleTextBlockStyle}" />
<TextBlock Text="系统与外观设置" Opacity="0.6" />
<StackPanel Margin="0,14,0,0" Spacing="4">
<ToggleSwitch x:Name="StartupSwitch" Header="开机启动"
OnContent="开机时自动启动" OffContent="开机时不启动"
Toggled="StartupSwitch_Toggled" />
<ToggleSwitch x:Name="WheelSwitch" Header="轮盘全局开关"
OnContent="已启用,全局快捷键可呼出轮盘" OffContent="已关闭,轮盘无法呼出"
Toggled="WheelSwitch_Toggled" />
</StackPanel>
<TextBlock Text="外观" Style="{StaticResource SubtitleTextBlockStyle}" Margin="0,20,0,0" />
<ComboBox x:Name="ThemeBox" Header="主题模式" MinWidth="240" HorizontalAlignment="Left"
SelectionChanged="ThemeBox_SelectionChanged">
<ComboBoxItem Content="跟随系统" />
<ComboBoxItem Content="日间" />
<ComboBoxItem Content="夜间" />
</ComboBox>
<TextBlock x:Name="VersionText" Opacity="0.55" Margin="0,28,0,0" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</Page>
@@ -0,0 +1,58 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OneClickRun.Core.Models;
namespace OneClickRun.App.Pages;
/// <summary>常规:开机启动、轮盘全局开关、主题、版本。</summary>
public sealed partial class GeneralPage : Page
{
private bool _loading;
public GeneralPage()
{
InitializeComponent();
Loaded += (_, _) => LoadFromSettings();
}
private void LoadFromSettings()
{
_loading = true;
var s = App.Instance.Settings;
StartupSwitch.IsOn = s.StartWithWindows;
WheelSwitch.IsOn = s.WheelEnabled;
ThemeBox.SelectedIndex = (int)s.ThemeMode;
var v = typeof(App).Assembly.GetName().Version;
VersionText.Text = "版本 " + (v == null ? "0.1.0" : v.ToString(3));
_loading = false;
}
private void StartupSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_loading) return;
App.Instance.Settings.StartWithWindows = StartupSwitch.IsOn;
SaveAndApply();
}
private void WheelSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_loading) return;
App.Instance.Settings.WheelEnabled = WheelSwitch.IsOn;
if (!WheelSwitch.IsOn) App.Instance.Wheel.Hide();
SaveAndApply();
}
private void ThemeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_loading || ThemeBox.SelectedIndex < 0) return;
App.Instance.Settings.ThemeMode = (ThemeMode)ThemeBox.SelectedIndex;
SaveAndApply();
}
private static void SaveAndApply()
{
var app = App.Instance;
app.SaveSettings();
app.ApplySettingsToSystem();
}
}
+52
View File
@@ -0,0 +1,52 @@
using OneClickRun.Core.Services;
namespace OneClickRun.App.Services;
/// <summary>简单滚动文件日志:%APPDATA%\OneClickRun\logs\app.log1MB × 5 份)。</summary>
public sealed class AppLogger
{
private const long MaxBytes = 1_000_000;
private const int MaxFiles = 5;
private readonly string _file;
private readonly object _lock = new();
public AppLogger()
{
var dir = Path.Combine(SettingsStore.DefaultDirectory, "logs");
Directory.CreateDirectory(dir);
_file = Path.Combine(dir, "app.log");
}
public void Info(string message) => Write("INFO", message);
public void Error(string context, Exception ex) => Write("ERROR", context + ": " + ex);
private void Write(string level, string message)
{
lock (_lock)
{
try
{
if (File.Exists(_file) && new FileInfo(_file).Length > MaxBytes) Rotate();
File.AppendAllText(_file, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + " [" + level + "] " + message + Environment.NewLine);
}
catch
{
// 日志失败不影响主流程
}
}
}
private void Rotate()
{
var oldest = _file + "." + MaxFiles;
if (File.Exists(oldest)) File.Delete(oldest);
for (int i = MaxFiles - 1; i >= 1; i--)
{
var from = _file + "." + i;
if (File.Exists(from)) File.Move(from, _file + "." + (i + 1));
}
File.Move(_file, _file + ".1");
}
}
@@ -0,0 +1,23 @@
using Windows.UI;
namespace OneClickRun.App.Services;
/// <summary>十六进制颜色字符串 → Windows.UI.Color。</summary>
internal static class ColorHelper
{
public static Color FromHex(string hex)
{
var value = hex.Trim().TrimStart('#');
if (value.Length == 6)
{
return Color.FromArgb(255, ParseByte(value, 0), ParseByte(value, 2), ParseByte(value, 4));
}
if (value.Length == 8)
{
return Color.FromArgb(ParseByte(value, 0), ParseByte(value, 2), ParseByte(value, 4), ParseByte(value, 6));
}
return Color.FromArgb(255, 128, 128, 128);
}
private static byte ParseByte(string s, int offset) => Convert.ToByte(s.Substring(offset, 2), 16);
}
@@ -0,0 +1,156 @@
using Microsoft.UI.Dispatching;
using OneClickRun.Core.Models;
namespace OneClickRun.App.Services;
/// <summary>
/// 全局快捷键服务:RegisterHotKey + 独立消息窗口线程;
/// 事件统一封送到 UI 线程。
/// </summary>
public sealed class HotkeyService : IDisposable
{
private const int IdSummon = 0x2001;
private const int IdDismiss = 0x2002;
private MessageWindow? _window;
private DispatcherQueue? _dispatcher;
private HotkeyDefinition _summon = HotkeyDefinition.Default;
private bool _enabled;
private bool _suspended;
private bool _summonRegistered;
private bool _dismissRegistered;
/// <summary>呼出键按下(UI 线程)。</summary>
public event Action? SummonPressed;
/// <summary>Esc 关闭键按下(UI 线程)。</summary>
public event Action? DismissPressed;
public void Start()
{
_dispatcher = DispatcherQueue.GetForCurrentThread();
_window = new MessageWindow();
_window.HotkeyPressed += OnHotkeyPressed;
if (!_window.Create())
{
throw new InvalidOperationException("无法创建全局快捷键消息窗口(Win32 错误码 " + _window.LastError + "");
}
}
private void OnHotkeyPressed(int id)
{
_dispatcher?.TryEnqueue(() =>
{
if (id == IdSummon) SummonPressed?.Invoke();
else if (id == IdDismiss) DismissPressed?.Invoke();
});
}
/// <summary>按当前设置注册/注销呼出键(轮盘关闭时完全不注册)。</summary>
public void ApplySettings(bool enabled, HotkeyDefinition summon)
{
_enabled = enabled;
_summon = summon.Clone();
UnregisterSummon();
if (_enabled && !_suspended)
{
if (!RegisterSummon()) App.Instance.LogInfo("呼出快捷键注册失败(可能被其他程序占用)");
}
}
/// <summary>尝试注册新呼出键;失败返回 false(原注册已被注销,调用方需回退)。</summary>
public bool TrySetHotkey(HotkeyDefinition definition)
{
UnregisterSummon();
_summon = definition.Clone();
if (!_enabled || _suspended) return true;
bool ok = RegisterSummon();
if (!ok) App.Instance.LogInfo("新快捷键注册失败(可能被系统或其他程序占用)");
return ok;
}
/// <summary>临时挂起(设置页录制快捷键期间)。</summary>
public void Suspend()
{
_suspended = true;
UnregisterSummon();
UnregisterDismiss();
}
public void Resume()
{
_suspended = false;
if (_enabled) RegisterSummon();
}
/// <summary>动态注册/注销 Esc 关闭键(仅轮盘可见时占用)。</summary>
public void SetDismissEnabled(bool enabled)
{
if (enabled)
{
if (_dismissRegistered || _suspended || _window == null) return;
bool ok = false;
_window.Invoke(() => ok = NativeMethods.RegisterHotKey(
_window.Handle, IdDismiss, NativeMethods.MOD_NOREPEAT, 0x1B));
_dismissRegistered = ok;
}
else
{
UnregisterDismiss();
}
}
private bool RegisterSummon()
{
if (_window == null || _summonRegistered) return _summonRegistered;
_summonRegistered = Register(IdSummon, _summon);
return _summonRegistered;
}
/// <summary>
/// RegisterHotKey 的 hWnd 必须属于调用线程,因此把注册操作封送到消息窗口线程执行。
/// </summary>
private bool Register(int id, HotkeyDefinition definition)
{
bool registered = false;
bool invoked = _window!.Invoke(() =>
{
registered = NativeMethods.RegisterHotKey(
_window.Handle, id,
(uint)definition.Modifiers | NativeMethods.MOD_NOREPEAT,
(uint)definition.Key);
});
if (!invoked || !registered)
{
if (!invoked) App.Instance.LogInfo("RegisterHotKey 失败:消息线程调用失败");
else App.Instance.LogInfo("RegisterHotKey 失败:Win32 错误码 " + System.Runtime.InteropServices.Marshal.GetLastWin32Error());
}
return invoked && registered;
}
private void UnregisterSummon()
{
if (_window != null && _summonRegistered)
{
_window.Invoke(() => NativeMethods.UnregisterHotKey(_window.Handle, IdSummon));
_summonRegistered = false;
}
}
private void UnregisterDismiss()
{
if (_window != null && _dismissRegistered)
{
_window.Invoke(() => NativeMethods.UnregisterHotKey(_window.Handle, IdDismiss));
_dismissRegistered = false;
}
}
public void Dispose()
{
UnregisterSummon();
UnregisterDismiss();
_window?.Dispose();
_window = null;
}
}
@@ -0,0 +1,172 @@
using System.Runtime.InteropServices;
namespace OneClickRun.App.Services;
/// <summary>
/// 运行在独立 STA 线程上的 Win32 消息窗口,用于接收 RegisterHotKey 的 WM_HOTKEY。
/// 独立线程自带 GetMessage 消息循环,不依赖 WinUI 的 DispatcherQueue 泵送。
/// </summary>
internal sealed class MessageWindow : IDisposable
{
internal delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
private const string ClassName = "OneClickRun.HotkeyMessageWindow";
private const uint WM_APP_INVOKE = 0x8000 + 1;
private readonly object _invokeLock = new();
private Action? _pendingAction;
private Exception? _pendingException;
private ManualResetEventSlim? _pendingDone;
private WndProcDelegate? _wndProc;
private Thread? _thread;
private bool _disposed;
/// <summary>WM_HOTKEY 的 id(由 RegisterHotKey 传入)。在消息线程上触发。</summary>
public event Action<int>? HotkeyPressed;
public IntPtr Handle { get; private set; }
/// <summary>最近一次 Win32 错误码(创建失败时用于诊断)。</summary>
public int LastError { get; private set; }
public bool Create()
{
using var ready = new ManualResetEventSlim(false);
_thread = new Thread(() => MessageLoop(ready))
{
IsBackground = true,
Name = "OneClickRun.HotkeyLoop",
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
if (!ready.Wait(TimeSpan.FromSeconds(5))) return false;
return Handle != IntPtr.Zero;
}
private void MessageLoop(ManualResetEventSlim ready)
{
_wndProc = WndProc;
var wc = new WNDCLASSEX
{
cbSize = (uint)Marshal.SizeOf<WNDCLASSEX>(),
lpfnWndProc = _wndProc,
hInstance = NativeMethods.GetModuleHandle(null),
lpszClassName = ClassName,
};
ushort atom = NativeMethods.RegisterClassEx(ref wc);
if (atom == 0) LastError = Marshal.GetLastWin32Error();
else
{
Handle = NativeMethods.CreateWindowEx(
0, ClassName, "", 0, 0, 0, 0, 0,
NativeMethods.HWND_MESSAGE, IntPtr.Zero, NativeMethods.GetModuleHandle(null), IntPtr.Zero);
if (Handle == IntPtr.Zero) LastError = Marshal.GetLastWin32Error();
}
ready.Set();
if (Handle == IntPtr.Zero) return;
while (NativeMethods.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0)
{
NativeMethods.TranslateMessage(ref msg);
NativeMethods.DispatchMessage(ref msg);
}
if (Handle != IntPtr.Zero)
{
NativeMethods.DestroyWindow(Handle);
Handle = IntPtr.Zero;
}
}
/// <summary>
/// 在消息窗口线程上同步执行一个操作(例如 RegisterHotKey,其 hWnd 必须属于调用线程)。
/// 调用方不得在消息线程自身调用此方法。失败返回 false,并把异常记录到 LastError。
/// </summary>
public bool Invoke(Action action)
{
if (Handle == IntPtr.Zero) return false;
if (Thread.CurrentThread == _thread) throw new InvalidOperationException("不能在消息线程上调用 Invoke");
ManualResetEventSlim done;
lock (_invokeLock)
{
if (_pendingAction != null) throw new InvalidOperationException("消息线程已有待执行操作");
_pendingAction = action;
_pendingException = null;
_pendingDone = done = new ManualResetEventSlim(false);
}
NativeMethods.PostMessage(Handle, WM_APP_INVOKE, IntPtr.Zero, IntPtr.Zero);
if (!done.Wait(TimeSpan.FromSeconds(3)))
{
lock (_invokeLock)
{
_pendingAction = null;
_pendingDone = null;
}
return false;
}
lock (_invokeLock)
{
var ex = _pendingException;
_pendingAction = null;
_pendingException = null;
_pendingDone = null;
if (ex != null)
{
LastError = ex.HResult;
return false;
}
return true;
}
}
private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
if (msg == NativeMethods.WM_HOTKEY)
{
HotkeyPressed?.Invoke(wParam.ToInt32());
return IntPtr.Zero;
}
if (msg == WM_APP_INVOKE)
{
Action? action;
ManualResetEventSlim? done;
lock (_invokeLock)
{
action = _pendingAction;
done = _pendingDone;
_pendingAction = null;
_pendingDone = null;
}
if (action != null)
{
try
{
action();
}
catch (Exception ex)
{
lock (_invokeLock) _pendingException = ex;
}
finally
{
done?.Set();
}
}
return IntPtr.Zero;
}
return NativeMethods.DefWindowProc(hWnd, msg, wParam, lParam);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (Handle != IntPtr.Zero)
{
NativeMethods.PostMessage(Handle, NativeMethods.WM_QUIT, IntPtr.Zero, IntPtr.Zero);
}
_thread?.Join(TimeSpan.FromSeconds(2));
_thread = null;
}
}
@@ -0,0 +1,132 @@
using System.Runtime.InteropServices;
namespace OneClickRun.App.Services;
/// <summary>项目用到的 Win32 P/Invoke 声明。</summary>
internal static class NativeMethods
{
internal const uint MOD_ALT = 0x0001;
internal const uint MOD_CONTROL = 0x0002;
internal const uint MOD_SHIFT = 0x0004;
internal const uint MOD_WIN = 0x0008;
internal const uint MOD_NOREPEAT = 0x4000;
internal const uint WM_HOTKEY = 0x0312;
internal const uint WM_QUIT = 0x0012;
internal const uint MONITOR_DEFAULTTONEAREST = 0x2;
internal const int MDT_EFFECTIVE_DPI = 0;
internal static readonly IntPtr HWND_MESSAGE = new(-3);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll")]
internal static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll")]
internal static extern uint GetDoubleClickTime();
[DllImport("user32.dll")]
internal static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
internal static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern IntPtr CreateWindowEx(
uint dwExStyle, string lpClassName, string lpWindowName, uint dwStyle,
int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern ushort RegisterClassEx(ref WNDCLASSEX lpwcx);
[DllImport("user32.dll", SetLastError = true)]
internal static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
internal static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
internal static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
internal static extern IntPtr GetModuleHandle(string? lpModuleName);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool DestroyIcon(IntPtr hIcon);
[DllImport("Shcore.dll")]
internal static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
}
[StructLayout(LayoutKind.Sequential)]
internal struct POINT
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
internal struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WNDCLASSEX
{
public uint cbSize;
public uint style;
public MessageWindow.WndProcDelegate? lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
public string? lpszMenuName;
public string? lpszClassName;
public IntPtr hIconSm;
}
@@ -0,0 +1,45 @@
using Microsoft.Win32;
namespace OneClickRun.App.Services;
/// <summary>开机启动:写入/移除 HKCU\...\Run 注册表项。</summary>
public sealed class StartupService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "OneClickRun";
public void Apply(bool enabled)
{
App.Instance.LogInfo("开机启动设置应用:" + enabled);
try
{
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
if (key == null)
{
App.Instance.LogInfo("无法打开开机启动注册表项");
return;
}
if (enabled)
{
var exe = Environment.ProcessPath;
if (string.IsNullOrEmpty(exe))
{
App.Instance.LogInfo("开机启动设置失败:无法获取当前程序路径");
}
else
{
key.SetValue(ValueName, "\"" + exe + "\"");
App.Instance.LogInfo("开机启动已写入注册表:" + exe);
}
}
else
{
key.DeleteValue(ValueName, throwOnMissingValue: false);
}
}
catch (Exception ex)
{
App.Instance.LogError("写入开机启动注册表失败", ex);
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.UI.Xaml;
using OneClickRun.Core.Models;
namespace OneClickRun.App.Services;
/// <summary>把主题模式应用到各窗口的内容根元素(跟随系统用 Default)。</summary>
public static class ThemeService
{
public static void Apply(ThemeMode mode, params Window[] windows)
{
ElementTheme theme = mode switch
{
ThemeMode.Light => ElementTheme.Light,
ThemeMode.Dark => ElementTheme.Dark,
_ => ElementTheme.Default,
};
foreach (var window in windows)
{
if (window.Content is FrameworkElement root) root.RequestedTheme = theme;
}
}
}
@@ -0,0 +1,69 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace OneClickRun.App.Services;
/// <summary>
/// 透明窗口背景(WinUI3 无内置 TransparentBackdrop,采用微软 SystemBackdrop 示例方案:
/// 以全透明 CompositionColorBrush 作为系统背景刷,实现窗口内容的圆形外透明)。
/// </summary>
public sealed class TransparentBackdrop : SystemBackdrop
{
private static Windows.UI.Composition.Compositor? _compositor;
private static Windows.UI.Composition.CompositionBrush? _transparentBrush;
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
if (_transparentBrush == null)
{
WindowsSystemDispatcherQueueHelper.EnsureWindowsSystemDispatcherQueueController();
_compositor = new Windows.UI.Composition.Compositor();
_transparentBrush = _compositor.CreateColorBrush(Color.FromArgb(0, 255, 255, 255));
}
connectedTarget.SystemBackdrop = _transparentBrush;
}
protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget)
{
base.OnTargetDisconnected(disconnectedTarget);
disconnectedTarget.SystemBackdrop = null;
}
}
/// <summary>微软官方 SystemBackdrop 示例中的 DispatcherQueue 初始化辅助类。</summary>
internal static class WindowsSystemDispatcherQueueHelper
{
[StructLayout(LayoutKind.Sequential)]
private struct DispatcherQueueOptions
{
internal int dwSize;
internal int threadType;
internal int apartmentType;
}
[DllImport("CoreMessaging.dll")]
private static extern int CreateDispatcherQueueController(
[In] DispatcherQueueOptions options,
[In, Out, MarshalAs(UnmanagedType.IUnknown)] ref object? dispatcherQueueController);
private static object? _dispatcherQueueController;
public static void EnsureWindowsSystemDispatcherQueueController()
{
if (Windows.System.DispatcherQueue.GetForCurrentThread() != null) return;
if (_dispatcherQueueController == null)
{
var options = new DispatcherQueueOptions
{
dwSize = Marshal.SizeOf(typeof(DispatcherQueueOptions)),
threadType = 2, // DQTYPE_THREAD_CURRENT
apartmentType = 2, // DQTAT_COM_STA
};
_ = CreateDispatcherQueueController(options, ref _dispatcherQueueController);
}
}
}
@@ -0,0 +1,130 @@
using System.Drawing;
using System.Windows.Forms;
namespace OneClickRun.App.Services;
/// <summary>
/// 系统托盘图标:左键单击显示设置窗口;右键菜单提供
/// 轮盘全局开关(启用/关闭轮盘)、设置、退出三个选项。
/// </summary>
public sealed class TrayIconService : IDisposable
{
private readonly App _app;
private readonly NotifyIcon _notifyIcon;
private readonly ToolStripMenuItem _wheelToggleItem;
private readonly Icon _icon;
private bool _disposed;
public TrayIconService(App app)
{
_app = app;
_icon = LoadAppIcon();
_notifyIcon = new NotifyIcon
{
Icon = _icon,
Text = "一键运行",
Visible = true,
};
var menu = new ContextMenuStrip();
_wheelToggleItem = new ToolStripMenuItem();
var settingsItem = new ToolStripMenuItem("设置(&S)");
var exitItem = new ToolStripMenuItem("退出(&X)");
menu.Items.Add(_wheelToggleItem);
menu.Items.Add(settingsItem);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(exitItem);
menu.Opening += (_, _) => SyncState();
_wheelToggleItem.Click += (_, _) => ToggleWheel();
settingsItem.Click += (_, _) => ShowSettings();
exitItem.Click += (_, _) => ExitApplication();
_notifyIcon.ContextMenuStrip = menu;
_notifyIcon.MouseClick += OnTrayIconMouseClick;
_notifyIcon.DoubleClick += (_, _) => ShowSettings();
SyncState();
}
/// <summary>同步菜单项状态(每次打开菜单时也会调用)。</summary>
public void SyncState()
{
bool enabled = _app.Settings.WheelEnabled;
_wheelToggleItem.Checked = enabled;
_wheelToggleItem.Text = enabled ? "关闭轮盘(&W)" : "启用轮盘(&W)";
}
private void OnTrayIconMouseClick(object? sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left) ShowSettings();
}
private void ShowSettings()
{
_app.LogInfo("托盘:显示设置窗口");
_app.Main.ShowSettings();
}
private void ToggleWheel()
{
var app = _app;
app.Settings.WheelEnabled = !app.Settings.WheelEnabled;
if (!app.Settings.WheelEnabled) app.Wheel.Hide();
app.SaveSettings();
app.ApplySettingsToSystem();
SyncState();
app.LogInfo("托盘:轮盘全局开关已切换为" + (app.Settings.WheelEnabled ? "启用" : "关闭"));
}
private void ExitApplication()
{
_app.LogInfo("托盘:退出应用");
_app.ExitApplication();
}
/// <summary>从 Assets\AppIcon.png 生成托盘图标;失败时回退到系统默认图标。</summary>
private static Icon LoadAppIcon()
{
var path = Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.png");
if (File.Exists(path))
{
try
{
using var bitmap = new Bitmap(path);
IntPtr hicon = bitmap.GetHicon();
try
{
return (Icon)Icon.FromHandle(hicon).Clone();
}
finally
{
NativeMethods.DestroyIcon(hicon);
}
}
catch (Exception ex)
{
App.Instance.LogError("加载托盘图标失败", ex);
}
}
return (Icon)SystemIcons.Application.Clone();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
_notifyIcon.Visible = false;
}
catch
{
// 托盘图标可能已被系统移除
}
_notifyIcon.Dispose();
_icon.Dispose();
}
}
@@ -0,0 +1,248 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using OneClickRun.Core.Logic;
using OneClickRun.Core.Models;
using OneClickRun.Core.Services;
namespace OneClickRun.App.Services;
/// <summary>
/// 轮盘呼出/隐藏与选择状态机:
/// 点击显示(再按/点击外部/Esc/8 秒超时关闭)、长按显示、按住显示(松开即隐藏,滑动选择)。
/// </summary>
public sealed class WheelController : IDisposable
{
private readonly App _app;
private readonly DispatcherQueueTimer _holdPollTimer;
private readonly DispatcherQueueTimer _longPressTimer;
private readonly DispatcherQueueTimer _autoHideTimer;
private readonly DispatcherQueueTimer _doubleClickTimer;
private readonly DoubleClickState _doubleClickState = new();
private bool _holdModeActive;
public WheelWindow Window { get; }
public WheelController(App app)
{
_app = app;
Window = new WheelWindow();
Window.SectorClicked += OnSectorClicked;
Window.DismissRequested += OnDismissRequested;
app.Hotkeys.SummonPressed += OnSummonPressed;
app.Hotkeys.DismissPressed += Hide;
var queue = DispatcherQueue.GetForCurrentThread()
?? throw new InvalidOperationException("无法获取 DispatcherQueue");
_holdPollTimer = queue.CreateTimer();
_holdPollTimer.Interval = TimeSpan.FromMilliseconds(50);
_holdPollTimer.IsRepeating = true;
_holdPollTimer.Tick += OnHoldPollTick;
_longPressTimer = queue.CreateTimer();
_longPressTimer.Tick += OnLongPressTick;
_autoHideTimer = queue.CreateTimer();
_autoHideTimer.Interval = TimeSpan.FromSeconds(8);
_autoHideTimer.Tick += (_, _) => Hide();
_doubleClickTimer = queue.CreateTimer();
_doubleClickTimer.Tick += (_, _) =>
{
_doubleClickState.Reset();
Window.ClearPending();
_doubleClickTimer.Stop();
};
Window.SetItems(app.Settings.Commands);
}
/// <summary>设置变化后刷新轮盘指令。</summary>
public void RefreshSettings() => Window.SetItems(_app.Settings.Commands);
/// <summary>设置页“预览轮盘”按钮。</summary>
public void Preview()
{
if (Window.IsVisible)
{
Hide();
return;
}
Show(holdMode: false);
}
private void OnSummonPressed()
{
var s = _app.Settings;
if (!s.WheelEnabled) return;
switch (s.SummonMode)
{
case SummonMode.Click:
if (Window.IsVisible) Hide();
else Show(holdMode: false);
break;
case SummonMode.LongPress:
if (Window.IsVisible) return;
_longPressTimer.Interval = TimeSpan.FromMilliseconds(s.LongPressDelayMs);
_app.LogInfo("长按计时开始:" + s.LongPressDelayMs + "ms");
_longPressTimer.Start();
break;
case SummonMode.Hold:
if (!Window.IsVisible)
{
_holdModeActive = true;
Show(holdMode: true);
_holdPollTimer.Start();
}
break;
}
}
private void OnLongPressTick(DispatcherQueueTimer sender, object args)
{
sender.Stop();
// 计时结束仍按住才显示;提前松开无动作
bool stillDown = HotkeyStillDown(_app.Settings.Hotkey);
_app.LogInfo("长按计时结束:快捷键仍按住=" + stillDown);
if (stillDown) Show(holdMode: false);
}
private void OnHoldPollTick(DispatcherQueueTimer sender, object args)
{
Window.SetHoverIndex(Window.GetSectorIndexAtCursor());
if (!HotkeyStillDown(_app.Settings.Hotkey))
{
_holdPollTimer.Stop();
OnHoldReleased();
}
}
private void OnHoldReleased()
{
var s = _app.Settings;
if (s.SelectionMode == SelectionMode.Swipe && Window.TryGetSwipeIndex(s.Commands.Count, out int index))
{
Execute(index);
}
Hide();
}
private void OnSectorClicked(int index)
{
var s = _app.Settings;
if (s.SummonMode == SummonMode.Hold || _holdModeActive)
{
Execute(index);
return;
}
switch (s.SelectionMode)
{
case SelectionMode.SingleClick:
Execute(index);
break;
case SelectionMode.DoubleClick:
{
var now = DateTimeOffset.Now;
var window = TimeSpan.FromMilliseconds(NativeMethods.GetDoubleClickTime());
var outcome = _doubleClickState.OnClick(index, now, window);
if (outcome == DoubleClickOutcome.Execute)
{
_doubleClickTimer.Stop();
_doubleClickState.Reset();
Execute(index);
}
else
{
Window.SetPendingIndex(index);
_doubleClickTimer.Stop();
_doubleClickTimer.Interval = window;
_doubleClickTimer.Start();
}
break;
}
case SelectionMode.Swipe:
// 正常情况下仅按住模式可到此处;容错:按单击处理
Execute(index);
break;
}
}
private void OnDismissRequested()
{
if (!_holdModeActive) Hide();
}
private void Show(bool holdMode)
{
Window.ShowAt(GetWheelCenter(_app.Settings));
_app.Hotkeys.SetDismissEnabled(!holdMode);
if (!holdMode) _autoHideTimer.Start();
}
public void Hide()
{
_holdModeActive = false;
_holdPollTimer.Stop();
_longPressTimer.Stop();
_autoHideTimer.Stop();
_doubleClickTimer.Stop();
_doubleClickState.Reset();
Window.ClearPending();
Window.ClearHover();
_app.Hotkeys.SetDismissEnabled(false);
Window.Hide();
}
private bool Execute(int index)
{
var commands = _app.Settings.Commands;
if (index < 0 || index >= commands.Count) return false;
var item = commands[index];
Hide();
try
{
var error = CommandRunner.Validate(item);
if (error != null)
{
_app.ReportError("「" + item.Name + "」执行失败:" + error);
return false;
}
CommandRunner.Run(item);
_app.LogInfo("已执行指令「" + item.Name + "」");
return true;
}
catch (Exception ex)
{
_app.LogError("执行指令「" + item.Name + "」失败", ex);
_app.ReportError("「" + item.Name + "」执行失败:" + ex.Message);
return false;
}
}
private static POINT GetWheelCenter(AppSettings settings)
{
NativeMethods.GetCursorPos(out var cursor);
if (settings.WheelPosition == WheelPositionMode.Mouse) return cursor;
var monitor = NativeMethods.MonitorFromPoint(cursor, NativeMethods.MONITOR_DEFAULTTONEAREST);
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (NativeMethods.GetMonitorInfo(monitor, ref info))
{
return new POINT
{
X = (info.rcMonitor.Left + info.rcMonitor.Right) / 2,
Y = (info.rcMonitor.Top + info.rcMonitor.Bottom) / 2,
};
}
return cursor;
}
private static bool HotkeyStillDown(HotkeyDefinition definition)
{
return (NativeMethods.GetAsyncKeyState(definition.Key) & 0x8000) != 0;
}
public void Dispose() => Hide();
}
+9
View File
@@ -0,0 +1,9 @@
<Window
x:Class="OneClickRun.App.WheelWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:OneClickRun.App.Controls">
<Grid x:Name="Root">
<controls:WheelView x:Name="Wheel" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Window>
+121
View File
@@ -0,0 +1,121 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using OneClickRun.App.Controls;
using OneClickRun.App.Services;
using OneClickRun.Core.Logic;
using OneClickRun.Core.Models;
using Windows.Graphics;
using Windows.UI;
namespace OneClickRun.App;
/// <summary>
/// 轮盘悬浮窗:无边框、透明背景、置顶、不进 Alt+Tab。
/// 尺寸/位置一律使用物理像素;内容按 PerMonitorV2 DPI 自动缩放。
/// </summary>
public sealed partial class WheelWindow : Window
{
private const double WheelDiameterDips = 560;
private int _x;
private int _y;
private int _size;
private double _scale = 1.0;
public event Action<int>? SectorClicked;
public event Action? DismissRequested;
public WheelWindow()
{
InitializeComponent();
Title = "OneClickRun Wheel";
AppWindow.IsShownInSwitchers = false;
AppWindow.SetPresenter(AppWindowPresenterKind.Overlapped);
if (AppWindow.Presenter is OverlappedPresenter presenter)
{
presenter.SetBorderAndTitleBar(false, false);
presenter.IsAlwaysOnTop = true;
presenter.IsResizable = false;
presenter.IsMaximizable = false;
presenter.IsMinimizable = false;
}
SystemBackdrop = new TransparentBackdrop();
// alpha=1 的“透明”背景:肉眼不可见,但可命中鼠标事件(用于点击轮盘外关闭)
Root.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
Wheel.SectorClicked += i => SectorClicked?.Invoke(i);
Wheel.DismissRequested += () => DismissRequested?.Invoke();
}
public bool IsVisible => AppWindow.IsVisible;
public void SetItems(IReadOnlyList<CommandItem> items) => Wheel.SetItems(items);
public void SetHoverIndex(int index) => Wheel.SetHoverIndex(index);
public void SetPendingIndex(int index) => Wheel.SetPendingIndex(index);
public void ClearPending() => Wheel.ClearPending();
public void ClearHover() => Wheel.SetHoverIndex(-1);
/// <summary>按住模式轮询:光标所在扇区(窗口外也能计算方向)。</summary>
public int GetSectorIndexAtCursor()
{
if (Wheel.Count == 0) return -1;
NativeMethods.GetCursorPos(out var pt);
double cx = _x + _size / 2.0;
double cy = _y + _size / 2.0;
double dx = pt.X - cx;
double dy = pt.Y - cy;
double dist = Math.Sqrt(dx * dx + dy * dy);
if (dist < 76 * _scale || dist > 282 * _scale) return -1;
return WheelMath.GetSectorIndex(Wheel.Count, WheelMath.AngleFromVector(dx, dy));
}
/// <summary>滑动选择:松开时按光标相对轮盘中心方位决定扇区(死区 24 DIP)。</summary>
public bool TryGetSwipeIndex(int count, out int index)
{
index = -1;
if (!NativeMethods.GetCursorPos(out var pt)) return false;
double cx = _x + _size / 2.0;
double cy = _y + _size / 2.0;
return WheelMath.TryGetSwipeIndex(count, pt.X - cx, pt.Y - cy, 24 * _scale, out index);
}
/// <summary>在屏幕指定中心点显示轮盘(自动钳制到工作区并换算 DPI)。</summary>
internal void ShowAt(POINT center)
{
var monitor = NativeMethods.MonitorFromPoint(center, NativeMethods.MONITOR_DEFAULTTONEAREST);
_scale = GetMonitorScale(monitor);
_size = (int)Math.Round(WheelDiameterDips * _scale);
int half = _size / 2;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (NativeMethods.GetMonitorInfo(monitor, ref info))
{
int left = Math.Max(info.rcWork.Left + half, Math.Min(info.rcWork.Right - half, center.X));
int top = Math.Max(info.rcWork.Top + half, Math.Min(info.rcWork.Bottom - half, center.Y));
center.X = left;
center.Y = top;
}
_x = center.X - half;
_y = center.Y - half;
AppWindow.MoveAndResize(new RectInt32(_x, _y, _size, _size));
if (!AppWindow.IsVisible) AppWindow.Show();
Activate();
}
public void Hide() => AppWindow.Hide();
private static double GetMonitorScale(IntPtr monitor)
{
if (NativeMethods.GetDpiForMonitor(monitor, NativeMethods.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0 && dpiX > 0)
{
return dpiX / 96.0;
}
return 1.0;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="OneClickRun.app" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>
+47
View File
@@ -0,0 +1,47 @@
using OneClickRun.Core.Models;
namespace OneClickRun.Core.Data;
/// <summary>图标选项(Segoe MDL2 / Fluent Icons 字形)。</summary>
public sealed record GlyphOption(string Name, string Glyph);
public static class GlyphCatalog
{
public static readonly GlyphOption[] All =
{
new("编辑", "\uE70F"),
new("计算器", "\uE8EF"),
new("文件夹", "\uE8B7"),
new("地球", "\uE774"),
new("设置", "\uE713"),
new("应用", "\uE756"),
new("页面", "\uE7C3"),
new("命令", "\uE943"),
new("文档", "\uE8A5"),
new("星标", "\uE734"),
new("搜索", "\uE768"),
new("网络", "\uE701"),
new("下载", "\uE896"),
new("电源", "\uE7E8"),
new("闪电", "\uE945"),
new("齿轮", "\uE790"),
};
public static string DefaultGlyphFor(ActionType type) => type switch
{
ActionType.App => "\uE756",
ActionType.Folder => "\uE8B7",
ActionType.Url => "\uE774",
ActionType.Script => "\uE943",
_ => "\uE756",
};
public static int IndexOf(string glyph)
{
for (int i = 0; i < All.Length; i++)
{
if (All[i].Glyph == glyph) return i;
}
return -1;
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace OneClickRun.Core.Data;
/// <summary>轮盘扇区调色板(12 色循环)。</summary>
public static class WheelPalette
{
public static readonly string[] Colors =
{
"#E5484D", "#F76B15", "#F5A623", "#30A46C", "#12A594", "#0091FF",
"#5E5CE6", "#BF5AF2", "#F65CB6", "#8E8E93", "#A2845E", "#3DB9D3",
};
public static string Get(int index)
{
if (Colors.Length == 0) return "#808080";
index %= Colors.Length;
if (index < 0) index += Colors.Length;
return Colors[index];
}
}
@@ -0,0 +1,41 @@
namespace OneClickRun.Core.Logic;
public enum DoubleClickOutcome
{
/// <summary>本次点击进入等待状态(未执行)。</summary>
Pending,
/// <summary>双击成立,应执行指令。</summary>
Execute,
}
/// <summary>双击选择的状态机:双击窗口内两次点击同一扇区才执行。</summary>
public sealed class DoubleClickState
{
private int _pendingIndex = -1;
private DateTimeOffset _lastClick = DateTimeOffset.MinValue;
public int PendingIndex => _pendingIndex;
public DoubleClickOutcome OnClick(int index, DateTimeOffset now, TimeSpan doubleClickWindow)
{
if (_pendingIndex == index && now - _lastClick <= doubleClickWindow)
{
Reset();
return DoubleClickOutcome.Execute;
}
_pendingIndex = index;
_lastClick = now;
return DoubleClickOutcome.Pending;
}
public bool IsPending(DateTimeOffset now, TimeSpan doubleClickWindow)
{
return _pendingIndex >= 0 && now - _lastClick <= doubleClickWindow;
}
public void Reset()
{
_pendingIndex = -1;
_lastClick = DateTimeOffset.MinValue;
}
}
+125
View File
@@ -0,0 +1,125 @@
using System.Text;
using OneClickRun.Core.Models;
namespace OneClickRun.Core.Logic;
/// <summary>快捷键的格式化与解析(形如 Ctrl+Alt+Q)。</summary>
public static class HotkeyFormat
{
private static readonly Dictionary<string, int> NamedKeys = new(StringComparer.OrdinalIgnoreCase)
{
["Space"] = 0x20, ["Enter"] = 0x0D, ["Return"] = 0x0D,
["Esc"] = 0x1B, ["Escape"] = 0x1B, ["Tab"] = 0x09, ["Backspace"] = 0x08,
["Delete"] = 0x2E, ["Del"] = 0x2E, ["Insert"] = 0x2D, ["Ins"] = 0x2D,
["Home"] = 0x24, ["End"] = 0x23, ["PageUp"] = 0x21, ["PageDown"] = 0x22,
["Up"] = 0x26, ["Down"] = 0x28, ["Left"] = 0x25, ["Right"] = 0x27,
["PrintScreen"] = 0x2C, ["Pause"] = 0x13,
["Oem1"] = 0xBA, ["Oem2"] = 0xBF, ["Oem3"] = 0xC0, ["Oem4"] = 0xDB,
["Oem5"] = 0xDC, ["Oem6"] = 0xDD, ["Oem7"] = 0xDE,
["OemComma"] = 0xBC, ["OemPeriod"] = 0xBE, ["OemMinus"] = 0xBD, ["OemPlus"] = 0xBB,
["Add"] = 0x6B, ["Subtract"] = 0x6D, ["Multiply"] = 0x6A, ["Divide"] = 0x6F, ["Decimal"] = 0x6E,
};
static HotkeyFormat()
{
for (int i = 1; i <= 24; i++) NamedKeys["F" + i] = 0x70 + i - 1;
for (int i = 0; i <= 9; i++) NamedKeys["NumPad" + i] = 0x60 + i;
}
private static Dictionary<int, string>? _keyNames;
private static Dictionary<int, string> KeyNames => _keyNames ??= BuildKeyNames();
private static Dictionary<int, string> BuildKeyNames()
{
var d = new Dictionary<int, string>();
foreach (var kvp in NamedKeys) d.TryAdd(kvp.Value, kvp.Key);
for (char c = 'A'; c <= 'Z'; c++) d.TryAdd(c, c.ToString());
for (char c = '0'; c <= '9'; c++) d.TryAdd(c, c.ToString());
return d;
}
public static string GetKeyName(int vk) => KeyNames.TryGetValue(vk, out var name) ? name : "VK" + vk.ToString("X2");
public static string Format(HotkeyDefinition def)
{
var sb = new StringBuilder();
if ((def.Modifiers & HotkeyModifiers.Control) != 0) sb.Append("Ctrl+");
if ((def.Modifiers & HotkeyModifiers.Alt) != 0) sb.Append("Alt+");
if ((def.Modifiers & HotkeyModifiers.Shift) != 0) sb.Append("Shift+");
if ((def.Modifiers & HotkeyModifiers.Win) != 0) sb.Append("Win+");
sb.Append(GetKeyName(def.Key));
return sb.ToString();
}
public static bool TryParse(string text, out HotkeyDefinition definition, out string error)
{
definition = HotkeyDefinition.Default;
error = "";
if (string.IsNullOrWhiteSpace(text))
{
error = "快捷键不能为空";
return false;
}
var parts = text.Trim().Split('+', StringSplitOptions.TrimEntries);
var mods = HotkeyModifiers.None;
int key = 0;
bool keyFound = false;
foreach (var part in parts)
{
switch (part.ToLowerInvariant())
{
case "ctrl":
case "control":
mods |= HotkeyModifiers.Control;
break;
case "alt":
mods |= HotkeyModifiers.Alt;
break;
case "shift":
mods |= HotkeyModifiers.Shift;
break;
case "win":
case "windows":
mods |= HotkeyModifiers.Win;
break;
default:
if (keyFound)
{
error = "快捷键只能包含一个按键";
return false;
}
if (part.Length == 1 && char.IsLetterOrDigit(part[0]))
{
key = char.ToUpperInvariant(part[0]);
}
else if (NamedKeys.TryGetValue(part, out int vk))
{
key = vk;
}
else
{
error = "无法识别的按键:" + part;
return false;
}
keyFound = true;
break;
}
}
if (!keyFound)
{
error = "快捷键缺少按键";
return false;
}
if ((mods & (HotkeyModifiers.Control | HotkeyModifiers.Alt | HotkeyModifiers.Win)) == 0)
{
error = "快捷键需包含 Ctrl、Alt 或 Win 键";
return false;
}
definition = new HotkeyDefinition { Modifiers = mods, Key = key };
return true;
}
}
+50
View File
@@ -0,0 +1,50 @@
namespace OneClickRun.Core.Logic;
/// <summary>轮盘几何与方位计算(角度以 12 点钟方向为 0°,顺时针增大)。</summary>
public static class WheelMath
{
/// <summary>把角度归一化到 [0, 360)。</summary>
public static double NormalizeAngle(double degreesFromTopClockwise)
{
double a = degreesFromTopClockwise % 360.0;
if (a < 0) a += 360.0;
return a;
}
/// <summary>角度 → 扇区索引。扇区 i 覆盖 [i*span, (i+1)*span)。</summary>
public static int GetSectorIndex(int count, double degreesFromTopClockwise)
{
if (count <= 0) return -1;
double span = 360.0 / count;
double a = NormalizeAngle(degreesFromTopClockwise);
int index = (int)(a / span);
return index >= count ? count - 1 : index;
}
/// <summary>向量 → 从顶部顺时针的角度(0-360)。</summary>
public static double AngleFromVector(double dx, double dy)
{
return NormalizeAngle(Math.Atan2(dx, -dy) * 180.0 / Math.PI);
}
/// <summary>极坐标(0° 在顶部,顺时针)→ 直角坐标。</summary>
public static (double X, double Y) PolarPoint(double cx, double cy, double radius, double degreesFromTopClockwise)
{
double rad = degreesFromTopClockwise * Math.PI / 180.0;
return (cx + radius * Math.Sin(rad), cy - radius * Math.Cos(rad));
}
/// <summary>
/// 滑动选择:根据鼠标相对轮盘中心的方位决定扇区;
/// 距中心小于 deadZoneRadius 时返回 false(死区,不选择)。
/// </summary>
public static bool TryGetSwipeIndex(int count, double dx, double dy, double deadZoneRadius, out int index)
{
index = -1;
if (count <= 0) return false;
double distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < deadZoneRadius) return false;
index = GetSectorIndex(count, AngleFromVector(dx, dy));
return true;
}
}
@@ -0,0 +1,56 @@
namespace OneClickRun.Core.Models;
/// <summary>应用全部设置,序列化为 %APPDATA%\OneClickRun\settings.json。</summary>
public sealed class AppSettings
{
public const int CurrentSchemaVersion = 1;
public const int MaxCommands = 12;
public const int MinLongPressMs = 200;
public const int MaxLongPressMs = 1500;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
public bool StartWithWindows { get; set; }
public bool WheelEnabled { get; set; } = true;
public WheelPositionMode WheelPosition { get; set; } = WheelPositionMode.Center;
public HotkeyDefinition Hotkey { get; set; } = HotkeyDefinition.Default;
public SummonMode SummonMode { get; set; } = SummonMode.Click;
public int LongPressDelayMs { get; set; } = 400;
public SelectionMode SelectionMode { get; set; } = SelectionMode.SingleClick;
public ThemeMode ThemeMode { get; set; } = ThemeMode.System;
public List<CommandItem> Commands { get; set; } = new();
/// <summary>首次运行时的默认设置(含 5 个演示指令)。</summary>
public static AppSettings CreateDefault()
{
var settings = new AppSettings();
string windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
settings.Commands.AddRange(new[]
{
new CommandItem { Name = "记事本", ActionType = ActionType.App, Target = Path.Combine(windowsDir, "System32", "notepad.exe"), IconGlyph = "\uE70F", ColorIndex = 0 },
new CommandItem { Name = "计算器", ActionType = ActionType.App, Target = Path.Combine(windowsDir, "System32", "calc.exe"), IconGlyph = "\uE8EF", ColorIndex = 1 },
new CommandItem { Name = "我的文档", ActionType = ActionType.Folder, Target = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), IconGlyph = "\uE8B7", ColorIndex = 2 },
new CommandItem { Name = "必应", ActionType = ActionType.Url, Target = "https://www.bing.com", IconGlyph = "\uE774", ColorIndex = 3 },
new CommandItem { Name = "Windows 设置", ActionType = ActionType.Url, Target = "ms-settings:", IconGlyph = "\uE713", ColorIndex = 4 },
});
return settings;
}
/// <summary>修正非法值,保证设置可用。</summary>
public void Normalize()
{
SchemaVersion = CurrentSchemaVersion;
Hotkey ??= HotkeyDefinition.Default;
if (Hotkey.Key <= 0 || Hotkey.Modifiers == HotkeyModifiers.None) Hotkey = HotkeyDefinition.Default;
Commands ??= new List<CommandItem>();
Commands.RemoveAll(c => c is null);
foreach (var c in Commands)
{
if (c.Id == Guid.Empty) c.Id = Guid.NewGuid();
if (string.IsNullOrWhiteSpace(c.Name)) c.Name = "未命名";
if (c.ColorIndex < 0) c.ColorIndex = 0;
if (string.IsNullOrEmpty(c.IconGlyph)) c.IconGlyph = "\uE756";
}
if (LongPressDelayMs < MinLongPressMs || LongPressDelayMs > MaxLongPressMs) LongPressDelayMs = 400;
if (Commands.Count > MaxCommands) Commands = Commands.Take(MaxCommands).ToList();
}
}
@@ -0,0 +1,29 @@
namespace OneClickRun.Core.Models;
/// <summary>轮盘上的一个快捷指令。</summary>
public sealed class CommandItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "新指令";
public ActionType ActionType { get; set; } = ActionType.App;
/// <summary>指令目标:程序路径 / 文件夹路径 / 网址 / 脚本路径。</summary>
public string Target { get; set; } = "";
/// <summary>附加参数(仅“打开软件”使用)。</summary>
public string? Arguments { get; set; }
/// <summary>图标字形(Segoe MDL2 / Fluent Icons 码位)。</summary>
public string IconGlyph { get; set; } = "\uE756";
/// <summary>调色板颜色索引。</summary>
public int ColorIndex { get; set; }
/// <summary>以管理员身份运行(软件/脚本)。</summary>
public bool RunAsAdmin { get; set; }
/// <summary>隐藏窗口运行(仅脚本)。</summary>
public bool HideWindow { get; set; }
}
+44
View File
@@ -0,0 +1,44 @@
namespace OneClickRun.Core.Models;
/// <summary>快捷指令类型。</summary>
public enum ActionType
{
App = 0,
Folder = 1,
Url = 2,
Script = 3,
}
/// <summary>主题模式。</summary>
public enum ThemeMode
{
System = 0,
Light = 1,
Dark = 2,
}
/// <summary>轮盘呼出方式。</summary>
public enum SummonMode
{
/// <summary>按一次快捷键显示/隐藏。</summary>
Click = 0,
/// <summary>长按快捷键一段时间后显示。</summary>
LongPress = 1,
/// <summary>按住快捷键时显示,松开即隐藏。</summary>
Hold = 2,
}
/// <summary>轮盘指令选择方式。</summary>
public enum SelectionMode
{
SingleClick = 0,
DoubleClick = 1,
Swipe = 2,
}
/// <summary>轮盘显示位置。</summary>
public enum WheelPositionMode
{
Center = 0,
Mouse = 1,
}
@@ -0,0 +1,25 @@
namespace OneClickRun.Core.Models;
/// <summary>全局快捷键修饰键,取值与 Win32 MOD_* 一致。</summary>
[Flags]
public enum HotkeyModifiers : uint
{
None = 0,
Alt = 0x1,
Control = 0x2,
Shift = 0x4,
Win = 0x8,
}
/// <summary>全局快捷键定义:修饰键 + 虚拟键码。</summary>
public sealed class HotkeyDefinition
{
public HotkeyModifiers Modifiers { get; set; } = HotkeyModifiers.Control | HotkeyModifiers.Alt;
/// <summary>Win32 虚拟键码(VK_*)。</summary>
public int Key { get; set; } = 0x51; // Q
public static HotkeyDefinition Default => new();
public HotkeyDefinition Clone() => new() { Modifiers = Modifiers, Key = Key };
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OneClickRun.Core</RootNamespace>
</PropertyGroup>
</Project>
@@ -0,0 +1,98 @@
using System.Diagnostics;
using System.Text;
using OneClickRun.Core.Models;
namespace OneClickRun.Core.Services;
/// <summary>把快捷指令翻译成进程启动信息并执行。</summary>
public static class CommandRunner
{
/// <summary>校验指令;返回 null 表示有效,否则返回错误说明。</summary>
public static string? Validate(CommandItem item)
{
if (string.IsNullOrWhiteSpace(item.Name)) return "名称不能为空";
if (string.IsNullOrWhiteSpace(item.Target)) return "目标不能为空";
return item.ActionType switch
{
ActionType.App => File.Exists(item.Target) ? null : "程序路径不存在",
ActionType.Folder => Directory.Exists(item.Target) ? null : "文件夹不存在",
ActionType.Script => File.Exists(item.Target) ? null : "脚本文件不存在",
ActionType.Url => IsValidUrl(item.Target) ? null : "网址无效",
_ => "未知的指令类型",
};
}
public static bool IsValidUrl(string url)
{
var normalized = NormalizeUrl(url);
return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Scheme);
}
/// <summary>没有协议时自动补 https://;已有协议(含 ms-settings: 等)保持不变。</summary>
public static string NormalizeUrl(string url)
{
var value = url.Trim();
if (value.Length == 0) return value;
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Scheme)) return value;
return "https://" + value;
}
public static ProcessStartInfo BuildStartInfo(CommandItem item)
{
switch (item.ActionType)
{
case ActionType.App:
{
var psi = new ProcessStartInfo { FileName = item.Target, UseShellExecute = true };
var dir = Path.GetDirectoryName(item.Target);
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) psi.WorkingDirectory = dir;
if (!string.IsNullOrWhiteSpace(item.Arguments)) psi.Arguments = item.Arguments;
if (item.RunAsAdmin) psi.Verb = "runas";
return psi;
}
case ActionType.Folder:
return new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = "\"" + item.Target + "\"",
UseShellExecute = false,
};
case ActionType.Url:
return new ProcessStartInfo { FileName = NormalizeUrl(item.Target), UseShellExecute = true };
case ActionType.Script:
{
bool isBatch = Path.GetExtension(item.Target).ToLowerInvariant() is ".cmd" or ".bat";
var args = new StringBuilder();
if (isBatch) args.Append("/c \"\"").Append(item.Target).Append("\"\"");
else args.Append("-NoProfile -ExecutionPolicy Bypass -File \"").Append(item.Target).Append('"');
if (item.RunAsAdmin)
{
return new ProcessStartInfo
{
FileName = isBatch ? "cmd.exe" : "powershell.exe",
Arguments = args.ToString(),
UseShellExecute = true,
Verb = "runas",
};
}
return new ProcessStartInfo
{
FileName = isBatch ? "cmd.exe" : "powershell.exe",
Arguments = args.ToString(),
UseShellExecute = false,
CreateNoWindow = item.HideWindow,
WindowStyle = item.HideWindow ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal,
};
}
default:
throw new NotSupportedException("不支持的指令类型:" + item.ActionType);
}
}
public static Process Run(CommandItem item)
{
var psi = BuildStartInfo(item);
return Process.Start(psi) ?? throw new InvalidOperationException("无法启动进程");
}
}
@@ -0,0 +1,66 @@
using System.Text.Json;
using OneClickRun.Core.Models;
namespace OneClickRun.Core.Services;
/// <summary>设置文件的读写:原子写入、损坏备份、缺失回退默认值。</summary>
public sealed class SettingsStore
{
public static string DefaultDirectory { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun");
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _filePath;
public SettingsStore(string directoryPath)
{
_filePath = Path.Combine(directoryPath, "settings.json");
}
public string FilePath => _filePath;
public AppSettings Load()
{
if (!File.Exists(_filePath)) return AppSettings.CreateDefault();
try
{
var json = File.ReadAllText(_filePath);
var settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? throw new JsonException("反序列化结果为空");
settings.Normalize();
return settings;
}
catch
{
TryBackupCorruptFile();
return AppSettings.CreateDefault();
}
}
public void Save(AppSettings settings)
{
settings.Normalize();
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var tmp = _filePath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(settings, JsonOptions));
File.Move(tmp, _filePath, overwrite: true);
}
private void TryBackupCorruptFile()
{
try
{
if (File.Exists(_filePath))
{
var backup = _filePath + ".corrupt-" + DateTime.Now.ToString("yyyyMMdd-HHmmss");
File.Copy(_filePath, backup, overwrite: true);
}
}
catch
{
// 备份失败不影响默认设置恢复
}
}
}
@@ -0,0 +1,127 @@
using System.Diagnostics;
using OneClickRun.Core.Models;
using OneClickRun.Core.Services;
using Xunit;
namespace OneClickRun.Core.Tests;
public class CommandRunnerTests
{
private static string SystemRoot => Environment.GetFolderPath(Environment.SpecialFolder.Windows);
[Fact]
public void App_BuildsShellExecuteInfo()
{
var item = new CommandItem { ActionType = ActionType.App, Target = SystemRoot + "\\explorer.exe", Arguments = "-n" };
var psi = CommandRunner.BuildStartInfo(item);
Assert.True(psi.UseShellExecute);
Assert.Equal("-n", psi.Arguments);
Assert.False(string.IsNullOrEmpty(psi.WorkingDirectory));
}
[Fact]
public void App_Admin_SetsVerb()
{
var item = new CommandItem { ActionType = ActionType.App, Target = SystemRoot + "\\explorer.exe", RunAsAdmin = true };
var psi = CommandRunner.BuildStartInfo(item);
Assert.Equal("runas", psi.Verb);
}
[Fact]
public void Folder_UsesExplorer()
{
var item = new CommandItem { ActionType = ActionType.Folder, Target = "C:\\Some Folder" };
var psi = CommandRunner.BuildStartInfo(item);
Assert.Equal("explorer.exe", psi.FileName);
Assert.Contains("\"C:\\Some Folder\"", psi.Arguments);
Assert.False(psi.UseShellExecute);
}
[Theory]
[InlineData("www.bing.com", "https://www.bing.com")]
[InlineData("bing.com", "https://bing.com")]
[InlineData("https://example.com/x", "https://example.com/x")]
[InlineData("ms-settings:", "ms-settings:")]
public void Url_Normalization(string input, string expected)
{
Assert.Equal(expected, CommandRunner.NormalizeUrl(input));
}
[Fact]
public void Script_BuildsPowerShellArgs()
{
var item = new CommandItem { ActionType = ActionType.Script, Target = "C:\\Scripts\\demo.ps1" };
var psi = CommandRunner.BuildStartInfo(item);
Assert.Equal("powershell.exe", psi.FileName);
Assert.Contains("-NoProfile", psi.Arguments);
Assert.Contains("-ExecutionPolicy Bypass", psi.Arguments);
Assert.Contains("-File \"C:\\Scripts\\demo.ps1\"", psi.Arguments);
Assert.False(psi.UseShellExecute);
Assert.False(psi.CreateNoWindow);
}
[Fact]
public void Script_Hidden_SetsNoWindow()
{
var item = new CommandItem { ActionType = ActionType.Script, Target = "C:\\a.ps1", HideWindow = true };
var psi = CommandRunner.BuildStartInfo(item);
Assert.True(psi.CreateNoWindow);
Assert.Equal(ProcessWindowStyle.Hidden, psi.WindowStyle);
}
[Fact]
public void Script_Admin_SetsVerb()
{
var item = new CommandItem { ActionType = ActionType.Script, Target = "C:\\a.ps1", RunAsAdmin = true };
var psi = CommandRunner.BuildStartInfo(item);
Assert.True(psi.UseShellExecute);
Assert.Equal("runas", psi.Verb);
}
[Theory]
[InlineData(ActionType.App)]
[InlineData(ActionType.Folder)]
[InlineData(ActionType.Script)]
[InlineData(ActionType.Url)]
public void Validate_MissingTarget_ReturnsError(ActionType type)
{
var item = new CommandItem { Name = "x", ActionType = type, Target = "" };
Assert.NotNull(CommandRunner.Validate(item));
}
[Fact]
public void Validate_ExistingApp_ReturnsNull()
{
var item = new CommandItem { Name = "x", ActionType = ActionType.App, Target = SystemRoot + "\\notepad.exe" };
Assert.Null(CommandRunner.Validate(item));
}
[Fact]
public void Validate_InvalidUrl_ReturnsError()
{
var item = new CommandItem { Name = "x", ActionType = ActionType.Url, Target = "::not a url::" };
Assert.NotNull(CommandRunner.Validate(item));
}
[Theory]
[InlineData(@"C:\Scripts\run.cmd")]
[InlineData(@"C:\Scripts\run.bat")]
public void Script_Batch_BuildsCmdArgs(string target)
{
var item = new CommandItem { ActionType = ActionType.Script, Target = target };
var psi = CommandRunner.BuildStartInfo(item);
Assert.Equal("cmd.exe", psi.FileName);
Assert.Equal("/c \"\"" + target + "\"\"", psi.Arguments);
Assert.False(psi.UseShellExecute);
}
[Fact]
public void Script_Batch_Admin_SetsVerb()
{
var item = new CommandItem { ActionType = ActionType.Script, Target = "C:\run.bat", RunAsAdmin = true };
var psi = CommandRunner.BuildStartInfo(item);
Assert.Equal("cmd.exe", psi.FileName);
Assert.True(psi.UseShellExecute);
Assert.Equal("runas", psi.Verb);
}
}
@@ -0,0 +1,56 @@
using OneClickRun.Core.Logic;
using Xunit;
namespace OneClickRun.Core.Tests;
public class DoubleClickStateTests
{
private static readonly TimeSpan Window = TimeSpan.FromMilliseconds(500);
private static readonly DateTimeOffset T0 = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void FirstClick_IsPending()
{
var state = new DoubleClickState();
Assert.Equal(DoubleClickOutcome.Pending, state.OnClick(2, T0, Window));
Assert.Equal(2, state.PendingIndex);
}
[Fact]
public void SecondClick_SameSector_WithinWindow_Executes()
{
var state = new DoubleClickState();
state.OnClick(2, T0, Window);
Assert.Equal(DoubleClickOutcome.Execute, state.OnClick(2, T0.AddMilliseconds(400), Window));
Assert.Equal(-1, state.PendingIndex);
}
[Fact]
public void SecondClick_AfterWindow_RestartsPending()
{
var state = new DoubleClickState();
state.OnClick(2, T0, Window);
Assert.Equal(DoubleClickOutcome.Pending, state.OnClick(2, T0.AddMilliseconds(600), Window));
Assert.Equal(2, state.PendingIndex);
}
[Fact]
public void SecondClick_DifferentSector_MovesPending()
{
var state = new DoubleClickState();
state.OnClick(2, T0, Window);
Assert.Equal(DoubleClickOutcome.Pending, state.OnClick(3, T0.AddMilliseconds(200), Window));
Assert.Equal(3, state.PendingIndex);
Assert.Equal(DoubleClickOutcome.Execute, state.OnClick(3, T0.AddMilliseconds(400), Window));
}
[Fact]
public void Reset_ClearsPending()
{
var state = new DoubleClickState();
state.OnClick(1, T0, Window);
state.Reset();
Assert.Equal(-1, state.PendingIndex);
Assert.False(state.IsPending(T0.AddMilliseconds(1), Window));
}
}
@@ -0,0 +1,53 @@
using OneClickRun.Core.Logic;
using OneClickRun.Core.Models;
using Xunit;
namespace OneClickRun.Core.Tests;
public class HotkeyFormatTests
{
[Fact]
public void Format_Default_IsCtrlAltQ()
{
Assert.Equal("Ctrl+Alt+Q", HotkeyFormat.Format(HotkeyDefinition.Default));
}
[Theory]
[InlineData("Ctrl+Alt+Q", HotkeyModifiers.Control | HotkeyModifiers.Alt, 0x51)]
[InlineData("Ctrl+Shift+F1", HotkeyModifiers.Control | HotkeyModifiers.Shift, 0x70)]
[InlineData("Win+Space", HotkeyModifiers.Win, 0x20)]
[InlineData("Alt+F4", HotkeyModifiers.Alt, 0x73)]
public void TryParse_ValidCombos(string text, HotkeyModifiers expectedMods, int expectedKey)
{
Assert.True(HotkeyFormat.TryParse(text, out var def, out var error), error);
Assert.Equal(expectedMods, def.Modifiers);
Assert.Equal(expectedKey, def.Key);
}
[Theory]
[InlineData("Q")]
[InlineData("Shift+Q")]
[InlineData("Ctrl+")]
[InlineData("Ctrl+Alt+Banana")]
[InlineData("Ctrl+Alt+A+B")]
[InlineData("")]
public void TryParse_InvalidCombos_ReturnsFalse(string text)
{
Assert.False(HotkeyFormat.TryParse(text, out _, out _));
}
[Fact]
public void RoundTrip_ParseThenFormat()
{
Assert.True(HotkeyFormat.TryParse("ctrl+alt+q", out var def, out _));
Assert.Equal("Ctrl+Alt+Q", HotkeyFormat.Format(def));
}
[Fact]
public void GetKeyName_KnownKeys()
{
Assert.Equal("F24", HotkeyFormat.GetKeyName(0x87));
Assert.Equal("A", HotkeyFormat.GetKeyName(0x41));
Assert.Equal("Space", HotkeyFormat.GetKeyName(0x20));
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OneClickRun.Core.Tests</RootNamespace>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OneClickRun.Core\OneClickRun.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
using OneClickRun.Core.Models;
using OneClickRun.Core.Services;
using Xunit;
namespace OneClickRun.Core.Tests;
public sealed class SettingsStoreTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "ocr-settings-test-" + Guid.NewGuid().ToString("N"));
private SettingsStore Store => new(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* 忽略 */ }
}
[Fact]
public void Load_MissingFile_ReturnsDefaults()
{
var settings = Store.Load();
Assert.True(settings.WheelEnabled);
Assert.Equal(5, settings.Commands.Count);
Assert.Equal(400, settings.LongPressDelayMs);
}
[Fact]
public void RoundTrip_SaveThenLoad()
{
var settings = new AppSettings
{
StartWithWindows = true,
WheelEnabled = false,
SummonMode = SummonMode.Hold,
SelectionMode = SelectionMode.Swipe,
ThemeMode = ThemeMode.Dark,
LongPressDelayMs = 700,
};
settings.Commands.Add(new CommandItem { Name = "测试", ActionType = ActionType.Url, Target = "https://example.com", ColorIndex = 5 });
Store.Save(settings);
var loaded = Store.Load();
Assert.True(loaded.StartWithWindows);
Assert.False(loaded.WheelEnabled);
Assert.Equal(SummonMode.Hold, loaded.SummonMode);
Assert.Equal(SelectionMode.Swipe, loaded.SelectionMode);
Assert.Equal(ThemeMode.Dark, loaded.ThemeMode);
Assert.Equal(700, loaded.LongPressDelayMs);
var cmd = Assert.Single(loaded.Commands);
Assert.Equal("测试", cmd.Name);
Assert.Equal(5, cmd.ColorIndex);
}
[Fact]
public void Load_CorruptFile_BacksUpAndReturnsDefaults()
{
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "settings.json"), "这不是 JSON {{{");
var settings = Store.Load();
Assert.True(settings.WheelEnabled);
Assert.NotEmpty(Directory.GetFiles(_dir, "settings.json.corrupt-*"));
}
[Fact]
public void Load_InvalidValues_AreNormalized()
{
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "settings.json"),
"{\"SchemaVersion\":1,\"LongPressDelayMs\":99999,\"Hotkey\":null}");
var settings = Store.Load();
Assert.Equal(400, settings.LongPressDelayMs);
Assert.Equal("Ctrl+Alt+Q", Logic.HotkeyFormat.Format(settings.Hotkey));
}
}
@@ -0,0 +1,81 @@
using OneClickRun.Core.Logic;
using Xunit;
namespace OneClickRun.Core.Tests;
public class WheelMathTests
{
[Theory]
[InlineData(0, 0)]
[InlineData(44.9, 0)]
[InlineData(45, 1)]
[InlineData(90, 2)]
[InlineData(359.9, 7)]
[InlineData(-45, 7)]
[InlineData(360, 0)]
public void GetSectorIndex_MapsAngles(double angle, int expected)
{
Assert.Equal(expected, WheelMath.GetSectorIndex(8, angle));
}
[Fact]
public void GetSectorIndex_SingleSector_AlwaysZero()
{
Assert.Equal(0, WheelMath.GetSectorIndex(1, 123.4));
}
[Fact]
public void GetSectorIndex_NoSectors_ReturnsMinusOne()
{
Assert.Equal(-1, WheelMath.GetSectorIndex(0, 10));
}
[Theory]
[InlineData(0, -1, 0)]
[InlineData(1, 0, 90)]
[InlineData(0, 1, 180)]
[InlineData(-1, 0, 270)]
public void AngleFromVector_CardinalDirections(double dx, double dy, double expected)
{
Assert.Equal(expected, WheelMath.AngleFromVector(dx, dy), 3);
}
[Fact]
public void PolarPoint_Top()
{
var (x, y) = WheelMath.PolarPoint(100, 100, 50, 0);
Assert.Equal(100, x, 3);
Assert.Equal(50, y, 3);
}
[Fact]
public void PolarPoint_Right()
{
var (x, y) = WheelMath.PolarPoint(100, 100, 50, 90);
Assert.Equal(150, x, 3);
Assert.Equal(100, y, 3);
}
[Fact]
public void TryGetSwipeIndex_DeadZone_ReturnsFalse()
{
Assert.False(WheelMath.TryGetSwipeIndex(8, 5, 0, 10, out _));
}
[Fact]
public void TryGetSwipeIndex_NoCommands_ReturnsFalse()
{
Assert.False(WheelMath.TryGetSwipeIndex(0, 100, 0, 10, out _));
}
[Theory]
[InlineData(0, -100, 0)]
[InlineData(100, 0, 2)]
[InlineData(0, 100, 4)]
[InlineData(-100, 0, 6)]
public void TryGetSwipeIndex_Directions(double dx, double dy, int expected)
{
Assert.True(WheelMath.TryGetSwipeIndex(8, dx, dy, 10, out int index));
Assert.Equal(expected, index);
}
}