初始提交:WinUI 3 一键运行轮盘工具
This commit is contained in:
+34
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 10(19041 及以上)或 Windows 11
|
||||
- .NET 8 SDK
|
||||
- Windows App SDK 2.4.0(NuGet 自动还原,应用已配置自包含 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、
|
||||
临时写入并恢复开机启动注册表项,并会启动/关闭记事本等测试进程。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 一键运行工具开发需求
|
||||
|
||||
## 概述
|
||||
|
||||
一键运行工具是采用游戏中常见的快捷轮盘的形式,快速启动软件、打开文件夹、打开链接等快捷指令。
|
||||
|
||||
## 运行平台与技术栈
|
||||
|
||||
- 运行平台:Windows平台(Win10,Win11)
|
||||
- 技术栈:WinUI3 + C#
|
||||
|
||||
## 功能需求
|
||||
|
||||
- 轮盘:在屏幕上一个圆环区域分割出多个扇形区域,每个区域分配一个操作或指令
|
||||
- 轮盘的呼出和隐藏:默认轮盘隐藏,通过指定快捷键呼出轮盘,选择轮盘上的指令后,自动隐藏轮盘
|
||||
- 轮盘支持的指令:打开软件、打开指定文件夹、打开指定网站、运行指定的脚本(powershell)等
|
||||
- 主界面:提供一个主界面,用于设置软件的各项功能,具体设置参考后续
|
||||
|
||||
## 快捷指令
|
||||
|
||||
- 打开软件:指定程序路径
|
||||
- 打开文件夹:指定文件夹路径
|
||||
- 打开网址:以默认浏览器打开指定网址
|
||||
- 运行脚本:指定脚本文件
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统的设置项包括:
|
||||
- 开机启动开关
|
||||
- 轮盘全局开关:关闭时,不再呼出轮盘
|
||||
- 轮盘显示位置:可选项包括:
|
||||
- 屏幕中心:呼出轮盘时在屏幕中心显示
|
||||
- 鼠标位置:呼出轮盘时在鼠标位置显示
|
||||
- 全局快捷键:呼出轮盘的快捷键,全局有效
|
||||
- 呼出轮盘操作:
|
||||
- 点击显示:按一次快捷键即显示轮盘
|
||||
- 长按显示:长按快捷键一定时间显示轮盘
|
||||
- 按住显示:按住快捷键时显示轮盘,松开即隐藏
|
||||
- 轮盘指令选择操作:
|
||||
- 单击选择:单击轮盘指令选择
|
||||
- 双击选择:双击轮盘指令选择
|
||||
- 滑动选择:选择鼠标滑动方向所指的指令(仅在按住显示轮盘时有效,以松开快捷键时,鼠标所在轮盘的方位选择指定的指令
|
||||
- 日间/黑夜模式切换:日间/黑夜/跟随系统
|
||||
- 轮盘指令设置,设置轮盘的各项指令
|
||||
|
||||
## UI风格
|
||||
|
||||
以现代简约风格为主,支持日间/黑夜模式
|
||||
+329
@@ -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
|
||||
@@ -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"
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="呼出与快捷键" Tag="behavior">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="快捷指令" Tag="commands">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem Content="关于" Tag="about">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using OneClickRun.Core.Services;
|
||||
|
||||
namespace OneClickRun.App.Services;
|
||||
|
||||
/// <summary>简单滚动文件日志:%APPDATA%\OneClickRun\logs\app.log(1MB × 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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user