feat:基础功能实现
feat: 重构 TodoList 架构,新增动态 API 与 MAUI 内嵌 Web 服务 feat:优化交互逻辑,优化发布流程
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
namespace TodoList.Maui.Services;
|
||||
|
||||
public static class AppMetadata
|
||||
{
|
||||
private const string AppNameText = "\u5F85\u529E\u4E8B\u9879";
|
||||
|
||||
public static string AppName => AppNameText;
|
||||
|
||||
public static string? GetDisplayVersion()
|
||||
{
|
||||
// 优先使用Assembly版本
|
||||
var asmVersion = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
if (asmVersion != null)
|
||||
{
|
||||
// 只返回主版本.次版本.修订版本 (如: 1.0.4)
|
||||
return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}";
|
||||
}
|
||||
|
||||
// 回退到AppInfo
|
||||
var versionString = AppInfo.Current.VersionString?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(versionString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Version.TryParse(versionString, out var parsed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}";
|
||||
}
|
||||
|
||||
public static string GetDisplayTitle()
|
||||
{
|
||||
var version = GetDisplayVersion();
|
||||
return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}";
|
||||
}
|
||||
|
||||
public static string GetTitleBarVersionText()
|
||||
{
|
||||
var version = GetDisplayVersion();
|
||||
return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}";
|
||||
}
|
||||
|
||||
public static string GetWindowTitle()
|
||||
{
|
||||
return GetTitleBarVersionText();
|
||||
}
|
||||
|
||||
public static string GetTrayTooltipText()
|
||||
{
|
||||
var text = GetDisplayTitle();
|
||||
return text.Length > 63 ? text[..63] : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Text.Json;
|
||||
using TodoList.Application;
|
||||
using TodoList.Application.DynamicApi;
|
||||
using TodoList.Maui.Models;
|
||||
using AppSettings = TodoList.Maui.Models.AppSettings;
|
||||
|
||||
namespace TodoList.Maui.Services;
|
||||
|
||||
public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
private WebApplication? _webApp;
|
||||
private readonly AppSettings _appSettings;
|
||||
|
||||
public bool IsRunning => _webApp != null;
|
||||
public string BaseUrl => _appSettings.WebServer.HostUrl;
|
||||
|
||||
public EmbeddedWebServerService(AppSettings appSettings)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_webApp != null) return;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
|
||||
builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl);
|
||||
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
|
||||
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowAll", policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (_appSettings.WebServer.IsUsingStatic)
|
||||
{
|
||||
ServeStaticFiles(app);
|
||||
}
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthorization();
|
||||
app.UseDynamicApi();
|
||||
app.MapControllers();
|
||||
|
||||
_webApp = app;
|
||||
|
||||
await _webApp.StartAsync();
|
||||
}
|
||||
|
||||
private void ServeStaticFiles(WebApplication app)
|
||||
{
|
||||
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
|
||||
if (!Directory.Exists(wwwrootPath))
|
||||
{
|
||||
Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileProvider = new PhysicalFileProvider(wwwrootPath);
|
||||
var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" };
|
||||
app.UseDefaultFiles(defaultFilesOptions);
|
||||
|
||||
var staticFileOptions = new StaticFileOptions
|
||||
{
|
||||
FileProvider = fileProvider,
|
||||
RequestPath = "",
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
|
||||
ctx.Context.Response.Headers["Pragma"] = "no-cache";
|
||||
ctx.Context.Response.Headers["Expires"] = "0";
|
||||
}
|
||||
};
|
||||
app.UseStaticFiles(staticFileOptions);
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.HasValue)
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
{
|
||||
context.Request.Path = "/index.html";
|
||||
}
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_webApp == null) return;
|
||||
|
||||
await _webApp.StopAsync();
|
||||
await _webApp.DisposeAsync();
|
||||
_webApp = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using TodoList.Maui.Services.Platforms;
|
||||
|
||||
namespace TodoList.Maui.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局热键服务工厂类,根据平台创建相应的热键服务实例
|
||||
/// </summary>
|
||||
public static class GlobalHotKeyServiceFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建适合当前平台的全局热键服务实例
|
||||
/// </summary>
|
||||
/// <returns>全局热键服务实例</returns>
|
||||
public static IGlobalHotKeyService Create()
|
||||
{
|
||||
#if WINDOWS
|
||||
return new WindowsGlobalHotKeyService();
|
||||
#elif MACCATALYST
|
||||
return new MacGlobalHotKeyService();
|
||||
#elif ANDROID || IOS
|
||||
return new MobileGlobalHotKeyService();
|
||||
#else
|
||||
return new NullGlobalHotKeyService();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 空热键服务实现类,用于不支持热键的平台
|
||||
/// </summary>
|
||||
public class NullGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 不支持热键
|
||||
/// </summary>
|
||||
public bool IsSupported => false;
|
||||
|
||||
/// <summary>
|
||||
/// 注册热键(空实现)
|
||||
/// </summary>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销热键(空实现)
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键(空实现)
|
||||
/// </summary>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Maui.Storage;
|
||||
using System.Text.Json;
|
||||
using TodoList.Maui.Models;
|
||||
|
||||
namespace TodoList.Maui.Services
|
||||
{
|
||||
public interface IHotKeySettingsService
|
||||
{
|
||||
HotKeyConfig GetConfig();
|
||||
void SaveConfig(HotKeyConfig config);
|
||||
void ResetToDefault();
|
||||
}
|
||||
|
||||
public class HotKeySettingsService : IHotKeySettingsService
|
||||
{
|
||||
private const string SettingsKey = "HotKeyConfig";
|
||||
private readonly AppSettings _appSettings;
|
||||
|
||||
public HotKeySettingsService(AppSettings appSettings)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
}
|
||||
|
||||
public HotKeyConfig GetConfig()
|
||||
{
|
||||
var json = Preferences.Get(SettingsKey, string.Empty);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return GetDefaultConfig();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HotKeyConfig>(json) ?? GetDefaultConfig();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return GetDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveConfig(HotKeyConfig config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
Preferences.Set(SettingsKey, json);
|
||||
}
|
||||
|
||||
public void ResetToDefault()
|
||||
{
|
||||
var defaultConfig = GetDefaultConfig();
|
||||
SaveConfig(defaultConfig);
|
||||
}
|
||||
|
||||
private HotKeyConfig GetDefaultConfig()
|
||||
{
|
||||
return new HotKeyConfig
|
||||
{
|
||||
Modifiers = _appSettings.HotKey.DefaultModifiers,
|
||||
Key = _appSettings.HotKey.DefaultKey,
|
||||
IsEnabled = _appSettings.HotKey.DefaultIsEnabled
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TodoList.Maui.Services;
|
||||
|
||||
public interface IEmbeddedWebServerService
|
||||
{
|
||||
bool IsRunning { get; }
|
||||
string BaseUrl { get; }
|
||||
Task StartAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TodoList.Maui.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局热键服务接口,定义跨平台热键功能
|
||||
/// </summary>
|
||||
public interface IGlobalHotKeyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册全局热键
|
||||
/// </summary>
|
||||
/// <param name="modifiers">修饰键(如 Alt, Control 等)</param>
|
||||
/// <param name="key">主键(如 X, C 等)</param>
|
||||
/// <param name="callback">热键触发时的回调函数</param>
|
||||
void RegisterHotKey(string modifiers, string key, Action callback);
|
||||
|
||||
/// <summary>
|
||||
/// 注销已注册的热键
|
||||
/// </summary>
|
||||
void UnregisterHotKey();
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键配置
|
||||
/// </summary>
|
||||
/// <param name="modifiers">新的修饰键</param>
|
||||
/// <param name="key">新的主键</param>
|
||||
void UpdateHotKey(string modifiers, string key);
|
||||
|
||||
/// <summary>
|
||||
/// 当前平台是否支持全局热键
|
||||
/// </summary>
|
||||
bool IsSupported { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace TodoList.Maui.Services
|
||||
{
|
||||
public interface ISystemTrayService
|
||||
{
|
||||
void Initialize(Microsoft.Maui.Controls.Window window, Action onShowWindow, Action onExit);
|
||||
void ShowBalloonTip(string title, string message);
|
||||
void Dispose();
|
||||
}
|
||||
|
||||
public class NullSystemTrayService : ISystemTrayService
|
||||
{
|
||||
public void Initialize(Microsoft.Maui.Controls.Window window, Action onShowWindow, Action onExit)
|
||||
{
|
||||
}
|
||||
|
||||
public void ShowBalloonTip(string title, string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#if MACCATALYST
|
||||
using AppKit;
|
||||
using Foundation;
|
||||
|
||||
namespace TodoList.Maui.Services.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// macOS 平台全局热键服务实现类
|
||||
/// 使用 AppKit 框架实现全局热键功能
|
||||
/// </summary>
|
||||
public class MacGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private NSObject? _eventMonitor;
|
||||
private Action? _callback;
|
||||
private bool _isRegistered;
|
||||
private NSEventModifierMask _currentModifiers;
|
||||
private string _currentKey;
|
||||
|
||||
/// <summary>
|
||||
/// macOS 平台支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>
|
||||
/// 注册全局热键
|
||||
/// </summary>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
_callback = callback;
|
||||
_currentModifiers = ParseModifiers(modifiers);
|
||||
_currentKey = key.ToUpper();
|
||||
|
||||
if (_isRegistered)
|
||||
{
|
||||
UnregisterHotKey();
|
||||
}
|
||||
|
||||
_eventMonitor = NSEvent.AddGlobalMonitorForEventsMatchingMask(
|
||||
NSEventMask.KeyDown,
|
||||
HandleKeyDown
|
||||
);
|
||||
|
||||
_isRegistered = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销全局热键
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
if (_eventMonitor != null)
|
||||
{
|
||||
NSEvent.RemoveMonitor(_eventMonitor);
|
||||
_eventMonitor = null;
|
||||
_isRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键配置
|
||||
/// </summary>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理键盘按下事件
|
||||
/// </summary>
|
||||
private void HandleKeyDown(NSEvent evt)
|
||||
{
|
||||
bool modifiersMatch = false;
|
||||
|
||||
if (_currentModifiers.HasFlag(AppKit.NSEventModifierMask.CommandKey) &&
|
||||
evt.ModifierFlags.HasFlag(AppKit.NSEventModifierMask.CommandKey))
|
||||
{
|
||||
modifiersMatch = true;
|
||||
}
|
||||
|
||||
if (_currentModifiers.HasFlag(AppKit.NSEventModifierMask.AlternateKey) &&
|
||||
evt.ModifierFlags.HasFlag(AppKit.NSEventModifierMask.AlternateKey))
|
||||
{
|
||||
modifiersMatch = true;
|
||||
}
|
||||
|
||||
if (_currentModifiers.HasFlag(AppKit.NSEventModifierMask.ControlKey) &&
|
||||
evt.ModifierFlags.HasFlag(AppKit.NSEventModifierMask.ControlKey))
|
||||
{
|
||||
modifiersMatch = true;
|
||||
}
|
||||
|
||||
if (_currentModifiers.HasFlag(AppKit.NSEventModifierMask.ShiftKey) &&
|
||||
evt.ModifierFlags.HasFlag(AppKit.NSEventModifierMask.ShiftKey))
|
||||
{
|
||||
modifiersMatch = true;
|
||||
}
|
||||
|
||||
if (modifiersMatch && evt.CharactersIgnoringModifiers == _currentKey)
|
||||
{
|
||||
_callback?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析修饰键字符串
|
||||
/// </summary>
|
||||
private AppKit.NSEventModifierMask ParseModifiers(string modifiers)
|
||||
{
|
||||
AppKit.NSEventModifierMask mask = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(modifiers)) return mask;
|
||||
|
||||
var parts = modifiers.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = part.Trim();
|
||||
if (p.Equals("Command", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Equals("Cmd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mask |= AppKit.NSEventModifierMask.CommandKey;
|
||||
}
|
||||
if (p.Equals("Option", StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Equals("Alt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mask |= AppKit.NSEventModifierMask.AlternateKey;
|
||||
}
|
||||
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mask |= AppKit.NSEventModifierMask.ControlKey;
|
||||
}
|
||||
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mask |= AppKit.NSEventModifierMask.ShiftKey;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,127 @@
|
||||
#if ANDROID
|
||||
using Android.Content;
|
||||
using Android.App;
|
||||
using AndroidX.Core.App;
|
||||
using AndroidX.Core.Content.PM;
|
||||
using AndroidX.Core.Graphics.Drawable;
|
||||
|
||||
namespace TodoList.Maui.Services.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// Android 平台全局热键服务实现类
|
||||
/// 由于 Android 限制全局热键,使用通知快捷方式作为替代方案
|
||||
/// </summary>
|
||||
public class MobileGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private Action? _callback;
|
||||
|
||||
/// <summary>
|
||||
/// Android 平台不支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => false;
|
||||
|
||||
/// <summary>
|
||||
/// 注册通知快捷方式作为热键替代方案
|
||||
/// </summary>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
_callback = callback;
|
||||
RegisterAndroidNotificationShortcut();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销快捷方式(空实现)
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新快捷方式配置
|
||||
/// </summary>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册 Android 通知快捷方式
|
||||
/// </summary>
|
||||
private void RegisterAndroidNotificationShortcut()
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = Android.App.Application.Context;
|
||||
var shortcutId = "quick_entry_shortcut";
|
||||
|
||||
var intent = new Intent(context, typeof(MainActivity));
|
||||
intent.SetAction(Intent.ActionView);
|
||||
intent.PutExtra("action", "quick_entry");
|
||||
|
||||
var shortcutInfo = new ShortcutInfoCompat.Builder(context, shortcutId)
|
||||
.SetShortLabel("快速记录")
|
||||
.SetLongLabel("快速记录任务")
|
||||
.SetIntent(intent)
|
||||
.Build();
|
||||
|
||||
ShortcutManagerCompat.PushDynamicShortcut(context, shortcutInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to register Android shortcut: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if IOS
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
|
||||
namespace TodoList.Maui.Services.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// iOS 平台全局热键服务实现类
|
||||
/// 由于 iOS 限制全局热键,提供空实现
|
||||
/// </summary>
|
||||
public class MobileGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private Action? _callback;
|
||||
|
||||
/// <summary>
|
||||
/// iOS 平台不支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => false;
|
||||
|
||||
/// <summary>
|
||||
/// 注册热键(空实现)
|
||||
/// </summary>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销热键(空实现)
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键(空实现)
|
||||
/// </summary>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,189 @@
|
||||
#if WINDOWS
|
||||
using System.Runtime.InteropServices;
|
||||
using WinRT.Interop;
|
||||
using MauiWindow = Microsoft.Maui.Controls.Window;
|
||||
|
||||
namespace TodoList.Maui.Services.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// Windows 平台全局热键服务实现类
|
||||
/// 使用 Windows API 实现全局热键功能
|
||||
/// </summary>
|
||||
public class WindowsGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private const int HOTKEY_ID = 9000;
|
||||
private const int WM_HOTKEY = 0x0312;
|
||||
private const int GWL_WNDPROC = -4;
|
||||
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
private IntPtr _windowHandle;
|
||||
private MauiWindow? _window;
|
||||
private Action? _callback;
|
||||
private bool _isRegistered;
|
||||
private uint _currentModifiers;
|
||||
private uint _currentKey;
|
||||
private IntPtr _originalWndProc;
|
||||
private WndProcDelegate? _wndProc;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 平台支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>
|
||||
/// 注册全局热键
|
||||
/// </summary>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
if (_window == null)
|
||||
{
|
||||
_window = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault();
|
||||
if (_window == null) return;
|
||||
}
|
||||
|
||||
if (_window.Handler?.PlatformView is not Microsoft.UI.Xaml.Window platformWindow) return;
|
||||
_windowHandle = WindowNative.GetWindowHandle(platformWindow);
|
||||
if (_windowHandle == IntPtr.Zero) return;
|
||||
|
||||
_callback = callback;
|
||||
_currentModifiers = ParseModifiers(modifiers);
|
||||
_currentKey = ParseKey(key);
|
||||
|
||||
if (_isRegistered)
|
||||
{
|
||||
UnregisterHotKey();
|
||||
}
|
||||
|
||||
if (RegisterHotKey(_windowHandle, HOTKEY_ID, _currentModifiers, _currentKey))
|
||||
{
|
||||
_isRegistered = true;
|
||||
EnsureWndProcHook();
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine("Failed to register hotkey");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销全局热键
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
if (_isRegistered)
|
||||
{
|
||||
UnregisterHotKey(_windowHandle, HOTKEY_ID);
|
||||
_isRegistered = false;
|
||||
}
|
||||
|
||||
if (_originalWndProc != IntPtr.Zero && _windowHandle != IntPtr.Zero)
|
||||
{
|
||||
SetWindowProc(_windowHandle, GWL_WNDPROC, _originalWndProc);
|
||||
_originalWndProc = IntPtr.Zero;
|
||||
_wndProc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键配置
|
||||
/// </summary>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureWndProcHook()
|
||||
{
|
||||
if (_originalWndProc != IntPtr.Zero) return;
|
||||
if (_windowHandle == IntPtr.Zero) return;
|
||||
|
||||
_wndProc = WndProc;
|
||||
var newWndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProc);
|
||||
_originalWndProc = SetWindowProc(_windowHandle, GWL_WNDPROC, newWndProcPtr);
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (msg == WM_HOTKEY && wParam == (IntPtr)HOTKEY_ID)
|
||||
{
|
||||
_callback?.Invoke();
|
||||
}
|
||||
|
||||
if (_originalWndProc != IntPtr.Zero)
|
||||
{
|
||||
return CallWindowProc(_originalWndProc, hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
return DefWindowProc(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析修饰键字符串
|
||||
/// </summary>
|
||||
private uint ParseModifiers(string modifiers)
|
||||
{
|
||||
uint mod = 0;
|
||||
if (string.IsNullOrEmpty(modifiers)) return mod;
|
||||
|
||||
var parts = modifiers.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = part.Trim();
|
||||
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= MOD_CONTROL;
|
||||
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= MOD_ALT;
|
||||
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= MOD_SHIFT;
|
||||
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= MOD_WIN;
|
||||
}
|
||||
return mod;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析主键
|
||||
/// </summary>
|
||||
private uint ParseKey(string key)
|
||||
{
|
||||
if (key.Length == 1)
|
||||
{
|
||||
char c = char.ToUpper(key[0]);
|
||||
if (c >= 'A' && c <= 'Z') return (uint)c;
|
||||
if (c >= '0' && c <= '9') return (uint)c;
|
||||
}
|
||||
return 0x58; // Default 'X'
|
||||
}
|
||||
|
||||
private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongW")]
|
||||
private static extern IntPtr SetWindowLong32(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
private static IntPtr SetWindowProc(IntPtr hWnd, int nIndex, IntPtr newProc)
|
||||
{
|
||||
return IntPtr.Size == 8
|
||||
? SetWindowLongPtr64(hWnd, nIndex, newProc)
|
||||
: SetWindowLong32(hWnd, nIndex, newProc);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,84 @@
|
||||
#if WINDOWS
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.UI.Xaml;
|
||||
using WinRT.Interop;
|
||||
using TodoList.Maui.Services;
|
||||
|
||||
namespace TodoList.Maui.Services.Platforms
|
||||
{
|
||||
public class WindowsSystemTrayService : ISystemTrayService, IDisposable
|
||||
{
|
||||
private NotifyIcon? _notifyIcon;
|
||||
private Microsoft.Maui.Controls.Window? _window;
|
||||
private Action? _onShowWindow;
|
||||
private Action? _onExit;
|
||||
private bool _disposed;
|
||||
|
||||
public void Initialize(Microsoft.Maui.Controls.Window window, Action onShowWindow, Action onExit)
|
||||
{
|
||||
_window = window;
|
||||
_onShowWindow = onShowWindow;
|
||||
_onExit = onExit;
|
||||
|
||||
_notifyIcon = new NotifyIcon();
|
||||
_notifyIcon.Icon = GetAppIcon();
|
||||
_notifyIcon.Text = GetNotifyIconText();
|
||||
_notifyIcon.Visible = true;
|
||||
|
||||
_notifyIcon.DoubleClick += (s, e) => _onShowWindow?.Invoke();
|
||||
|
||||
var contextMenu = new ContextMenuStrip();
|
||||
contextMenu.Items.Add("打开主界面", null, (s, e) => _onShowWindow?.Invoke());
|
||||
contextMenu.Items.Add("退出", null, (s, e) => _onExit?.Invoke());
|
||||
_notifyIcon.ContextMenuStrip = contextMenu;
|
||||
}
|
||||
|
||||
public void ShowBalloonTip(string title, string message)
|
||||
{
|
||||
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_notifyIcon?.Dispose();
|
||||
_notifyIcon = null;
|
||||
}
|
||||
|
||||
private Icon GetAppIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
var resourceName = assembly.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith("icon.ico"));
|
||||
|
||||
if (resourceName != null)
|
||||
{
|
||||
using (var stream = assembly.GetManifestResourceStream(resourceName))
|
||||
{
|
||||
if (stream != null)
|
||||
{
|
||||
return new Icon(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return SystemIcons.Application;
|
||||
}
|
||||
|
||||
private static string GetNotifyIconText()
|
||||
{
|
||||
return AppMetadata.GetTrayTooltipText();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user