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:
ShaoHua
2026-04-07 03:34:34 +08:00
parent 18d37fdd24
commit 7a4c516a20
85 changed files with 5774 additions and 127 deletions
@@ -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;
}
}
}