feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本
- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO - 数据:新增用户/会话/安全策略实体与 EF Core migrations - 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot - 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键 - 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置 - 文档:同步更新 README/docs 与协作规则
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services.Platforms;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 平台全局热键服务实现。
|
||||
/// 通过 <c>RegisterHotKey</c> 将热键注册到专用线程的消息队列中,避免依赖窗口句柄与 WndProc Hook。
|
||||
/// </summary>
|
||||
public sealed class WindowsGlobalHotKeyService : IGlobalHotKeyService, IDisposable
|
||||
{
|
||||
private const int HotKeyId = 9000;
|
||||
private const uint WmHotKey = 0x0312;
|
||||
private const uint WmQuit = 0x0012;
|
||||
private const uint WmAppExecute = 0x8001;
|
||||
|
||||
public const uint ModAlt = 0x0001;
|
||||
public const uint ModControl = 0x0002;
|
||||
public const uint ModShift = 0x0004;
|
||||
public const uint ModWin = 0x0008;
|
||||
|
||||
private readonly ConcurrentDictionary<int, Action> _actions = new();
|
||||
private int _actionId;
|
||||
private Thread? _thread;
|
||||
private uint _threadId;
|
||||
private ManualResetEventSlim? _threadStarted;
|
||||
|
||||
private Action? _callback;
|
||||
private bool _isRegistered;
|
||||
private uint _currentModifiers;
|
||||
private uint _currentKey;
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => OperatingSystem.IsWindows();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
if (!IsSupported) return;
|
||||
|
||||
_callback = callback;
|
||||
_currentModifiers = ParseModifiers(modifiers);
|
||||
_currentKey = ParseKey(key);
|
||||
|
||||
StartThreadIfNeeded();
|
||||
InvokeOnHotKeyThread(() =>
|
||||
{
|
||||
UnregisterHotKeyInternal();
|
||||
|
||||
if (_currentKey == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (RegisterHotKey(IntPtr.Zero, HotKeyId, _currentModifiers, _currentKey))
|
||||
{
|
||||
_isRegistered = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine("[WindowsGlobalHotKeyService] Failed to register hotkey.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
if (!IsSupported) return;
|
||||
|
||||
StartThreadIfNeeded();
|
||||
InvokeOnHotKeyThread(UnregisterHotKeyInternal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback == null) return;
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
|
||||
private void UnregisterHotKeyInternal()
|
||||
{
|
||||
if (_isRegistered)
|
||||
{
|
||||
UnregisterHotKey(IntPtr.Zero, HotKeyId);
|
||||
_isRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartThreadIfNeeded()
|
||||
{
|
||||
if (_thread != null) return;
|
||||
|
||||
_threadStarted = new ManualResetEventSlim(false);
|
||||
_thread = new Thread(MessageLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Hua.Todo HotKey Thread"
|
||||
};
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
_thread.Start();
|
||||
_threadStarted.Wait();
|
||||
}
|
||||
|
||||
private void MessageLoop()
|
||||
{
|
||||
_threadId = GetCurrentThreadId();
|
||||
_threadStarted?.Set();
|
||||
|
||||
while (GetMessage(out var msg, IntPtr.Zero, 0, 0) != 0)
|
||||
{
|
||||
if (msg.message == WmHotKey && msg.wParam == (UIntPtr)HotKeyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
_callback?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[WindowsGlobalHotKeyService] Hotkey callback failed: {ex}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.message == WmAppExecute)
|
||||
{
|
||||
var id = unchecked((int)msg.wParam);
|
||||
if (_actions.TryRemove(id, out var action))
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[WindowsGlobalHotKeyService] Action execution failed: {ex}");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.message == WmQuit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
|
||||
_threadStarted?.Dispose();
|
||||
_threadStarted = null;
|
||||
}
|
||||
|
||||
private void InvokeOnHotKeyThread(Action action)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _actionId);
|
||||
_actions[id] = action;
|
||||
|
||||
if (!PostThreadMessage(_threadId, WmAppExecute, (UIntPtr)id, IntPtr.Zero))
|
||||
{
|
||||
_actions.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ParseModifiers(string modifiers)
|
||||
{
|
||||
uint mod = 0;
|
||||
if (string.IsNullOrWhiteSpace(modifiers)) return mod;
|
||||
|
||||
var parts = modifiers.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = part.Trim();
|
||||
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= ModControl;
|
||||
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= ModAlt;
|
||||
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= ModShift;
|
||||
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= ModWin;
|
||||
}
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
private static uint ParseKey(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return 0;
|
||||
|
||||
if (key.Length == 1)
|
||||
{
|
||||
var c = char.ToUpperInvariant(key[0]);
|
||||
if (c is >= 'A' and <= 'Z') return c;
|
||||
if (c is >= '0' and <= '9') return c;
|
||||
}
|
||||
|
||||
return 0x58; // Default 'X'
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
UnregisterHotKey();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (_threadId != 0)
|
||||
{
|
||||
PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public UIntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern sbyte GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool TranslateMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref Msg lpMsg);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services.Platforms;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 全局键盘事件处理器。
|
||||
/// 用于监听按键并向上层暴露事件(例如 Esc),以保证在 WebView 获得焦点时依旧可触发窗口级交互。
|
||||
/// </summary>
|
||||
public sealed class WindowsKeyboardHandler : IDisposable
|
||||
{
|
||||
private readonly KeyboardHook _keyboardHook;
|
||||
private bool _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// 当检测到 Esc 键抬起时触发。
|
||||
/// </summary>
|
||||
public event EventHandler? EscKeyPressed;
|
||||
|
||||
/// <summary>
|
||||
/// 创建 <see cref="WindowsKeyboardHandler"/>。
|
||||
/// </summary>
|
||||
public WindowsKeyboardHandler()
|
||||
{
|
||||
_keyboardHook = new KeyboardHook();
|
||||
_keyboardHook.KeyPressed += OnKeyPressed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开始监听键盘事件。
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_keyboardHook.Hook();
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressedEventArgs e)
|
||||
{
|
||||
if (e.Key == 0x1B && !e.IsKeyDown)
|
||||
{
|
||||
EscKeyPressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
_keyboardHook.Unhook();
|
||||
_keyboardHook.KeyPressed -= OnKeyPressed;
|
||||
}
|
||||
|
||||
private sealed class KeyboardHook : IDisposable
|
||||
{
|
||||
private const int WhKeyboardLl = 13;
|
||||
private const int WmKeyDown = 0x0100;
|
||||
private const int WmKeyUp = 0x0101;
|
||||
private const int WmSysKeyDown = 0x0104;
|
||||
private const int WmSysKeyUp = 0x0105;
|
||||
|
||||
private readonly LowLevelKeyboardProc _proc;
|
||||
private IntPtr _hookId = IntPtr.Zero;
|
||||
private bool _isDisposed;
|
||||
|
||||
public event EventHandler<KeyPressedEventArgs>? KeyPressed;
|
||||
|
||||
public KeyboardHook()
|
||||
{
|
||||
_proc = HookCallback;
|
||||
}
|
||||
|
||||
public void Hook()
|
||||
{
|
||||
if (_hookId != IntPtr.Zero) return;
|
||||
_hookId = SetWindowsHookEx(WhKeyboardLl, _proc, GetModuleHandle(null), 0);
|
||||
}
|
||||
|
||||
public void Unhook()
|
||||
{
|
||||
if (_hookId == IntPtr.Zero) return;
|
||||
UnhookWindowsHookEx(_hookId);
|
||||
_hookId = IntPtr.Zero;
|
||||
}
|
||||
|
||||
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0)
|
||||
{
|
||||
var vkCode = Marshal.ReadInt32(lParam);
|
||||
var isKeyDown = wParam == (IntPtr)WmKeyDown || wParam == (IntPtr)WmSysKeyDown;
|
||||
var isKeyUp = wParam == (IntPtr)WmKeyUp || wParam == (IntPtr)WmSysKeyUp;
|
||||
|
||||
if (isKeyDown || isKeyUp)
|
||||
{
|
||||
KeyPressed?.Invoke(this, new KeyPressedEventArgs(vkCode, isKeyDown));
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(_hookId, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
Unhook();
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
private static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
}
|
||||
|
||||
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
private sealed class KeyPressedEventArgs : EventArgs
|
||||
{
|
||||
public int Key { get; }
|
||||
public bool IsKeyDown { get; }
|
||||
|
||||
public KeyPressedEventArgs(int key, bool isKeyDown)
|
||||
{
|
||||
Key = key;
|
||||
IsKeyDown = isKeyDown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user