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,33 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Hua.Todo.Avalonia.App"
|
||||
xmlns:local="using:Hua.Todo.Avalonia"
|
||||
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
|
||||
x:DataType="vm:AppTrayViewModel"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="{Binding TrayTooltipText}"
|
||||
Command="{Binding ShowMainWindowCommand}">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,270 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using Hua.Todo.Avalonia.Services;
|
||||
using Hua.Todo.Avalonia.Services.Platforms;
|
||||
using Hua.Todo.Avalonia.ViewModels;
|
||||
using Hua.Todo.Avalonia.Views;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Hua.Todo.Avalonia;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia 应用入口。
|
||||
/// 负责启动嵌入式 WebServer、创建主窗口并初始化 WebView 能力。
|
||||
/// </summary>
|
||||
public partial class App : global::Avalonia.Application
|
||||
{
|
||||
private AppSettings? _appSettings;
|
||||
private IEmbeddedWebServerService? _webServer;
|
||||
private IHotKeySettingsService? _hotKeySettingsService;
|
||||
private IGlobalHotKeyService? _hotKeyService;
|
||||
private HotKeyConfig? _hotKeyConfig;
|
||||
private WindowsKeyboardHandler? _keyboardHandler;
|
||||
private MainWindow? _mainWindow;
|
||||
private bool _isExitRequested;
|
||||
private bool _isHotkeyRegistered;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化应用资源并加载配置。
|
||||
/// </summary>
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册应用级服务。
|
||||
/// </summary>
|
||||
public override void RegisterServices()
|
||||
{
|
||||
base.RegisterServices();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
_appSettings = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[App] Failed to load settings: {ex.Message}");
|
||||
}
|
||||
|
||||
_appSettings ??= new AppSettings();
|
||||
|
||||
EnsureDefaultConnectionString(_appSettings);
|
||||
}
|
||||
|
||||
private static void EnsureDefaultConnectionString(AppSettings appSettings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(appSettings.WebServer.ConnectionString))
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dbDir = Path.Combine(localAppData, "Hua.Todo");
|
||||
if (!Directory.Exists(dbDir))
|
||||
{
|
||||
Directory.CreateDirectory(dbDir);
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(dbDir, "Hua.Todo.db");
|
||||
appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia 框架初始化完成回调。
|
||||
/// 启动嵌入式 WebServer(后台启动并捕获异常,避免阻塞/崩溃),并创建主窗口。
|
||||
/// </summary>
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
|
||||
_webServer = new EmbeddedWebServerService(_appSettings!);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _webServer.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[App] Web server start failed: {ex}");
|
||||
}
|
||||
});
|
||||
|
||||
_hotKeySettingsService = new HotKeySettingsService(_appSettings!);
|
||||
_hotKeyConfig = _hotKeySettingsService.GetConfig();
|
||||
_hotKeyService = GlobalHotKeyServiceFactory.Create();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_keyboardHandler = new WindowsKeyboardHandler();
|
||||
_keyboardHandler.EscKeyPressed += (_, _) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_mainWindow is { IsVisible: true })
|
||||
{
|
||||
_mainWindow.WindowState = WindowState.Minimized;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
DataContext = new AppTrayViewModel(
|
||||
AppMetadata.GetTrayTooltipText(),
|
||||
ShowMainWindow,
|
||||
() => ExitApplication(desktop));
|
||||
|
||||
_mainWindow = new MainWindow(_appSettings!, _webServer)
|
||||
{
|
||||
Width = 450,
|
||||
Height = 640,
|
||||
Title = AppMetadata.GetWindowTitle(),
|
||||
};
|
||||
|
||||
_mainWindow.Opened += (_, _) =>
|
||||
{
|
||||
RegisterHotkey();
|
||||
_keyboardHandler?.Start();
|
||||
};
|
||||
|
||||
_mainWindow.Closing += (_, e) =>
|
||||
{
|
||||
if (_isExitRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
_mainWindow.Hide();
|
||||
};
|
||||
|
||||
desktop.MainWindow = _mainWindow;
|
||||
|
||||
desktop.Exit += async (s, e) =>
|
||||
{
|
||||
_keyboardHandler?.Dispose();
|
||||
_keyboardHandler = null;
|
||||
|
||||
if (_isHotkeyRegistered)
|
||||
{
|
||||
_hotKeyService?.UnregisterHotKey();
|
||||
_isHotkeyRegistered = false;
|
||||
}
|
||||
|
||||
if (_hotKeyService is IDisposable disposableHotKeyService)
|
||||
{
|
||||
disposableHotKeyService.Dispose();
|
||||
}
|
||||
|
||||
if (_webServer != null)
|
||||
{
|
||||
await _webServer.StopAsync();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void ExitApplication(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
_isExitRequested = true;
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
if (_mainWindow == null) return;
|
||||
|
||||
if (!_mainWindow.IsVisible)
|
||||
{
|
||||
_mainWindow.Show();
|
||||
}
|
||||
|
||||
if (_mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
|
||||
private void RegisterHotkey()
|
||||
{
|
||||
if (_hotKeyService == null || _hotKeySettingsService == null) return;
|
||||
|
||||
var config = _hotKeyConfig ?? _hotKeySettingsService.GetConfig();
|
||||
_hotKeyConfig = config;
|
||||
|
||||
if (_hotKeyService.IsSupported && config.IsEnabled)
|
||||
{
|
||||
_hotKeyService.RegisterHotKey(config.Modifiers, config.Key, () =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(ShowMainWindow);
|
||||
});
|
||||
_isHotkeyRegistered = true;
|
||||
}
|
||||
|
||||
config.PropertyChanged -= OnHotKeyConfigPropertyChanged;
|
||||
config.PropertyChanged += OnHotKeyConfigPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnHotKeyConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (_hotKeyService == null || _hotKeySettingsService == null || _hotKeyConfig == null) return;
|
||||
|
||||
if (e.PropertyName != nameof(HotKeyConfig.Modifiers) &&
|
||||
e.PropertyName != nameof(HotKeyConfig.Key) &&
|
||||
e.PropertyName != nameof(HotKeyConfig.IsEnabled))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hotKeySettingsService.SaveConfig(_hotKeyConfig);
|
||||
|
||||
if (_hotKeyConfig.IsEnabled && _hotKeyService.IsSupported)
|
||||
{
|
||||
_hotKeyService.UpdateHotKey(_hotKeyConfig.Modifiers, _hotKeyConfig.Key);
|
||||
_isHotkeyRegistered = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_hotKeyService.UnregisterHotKey();
|
||||
_isHotkeyRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
foreach (var plugin in dataValidationPluginsToRemove)
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,64 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<TodoWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</TodoWebDir>
|
||||
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
|
||||
<SkipWebBuild>false</SkipWebBuild>
|
||||
<ForceWebBuild>false</ForceWebBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="BuildTodoWeb" BeforeTargets="BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
|
||||
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
|
||||
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
|
||||
<Exec Command="npm run build" WorkingDirectory="$(TodoWebDir)" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyTodoWebDistToWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="Exists('$(TodoWebDistDir)')">
|
||||
<ItemGroup>
|
||||
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
|
||||
<MakeDir Directories="$(TargetDir)wwwroot" />
|
||||
|
||||
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Models;
|
||||
|
||||
public class AppSettings
|
||||
{
|
||||
[JsonPropertyName("WebServer")]
|
||||
public WebServerSettings WebServer { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("HotKey")]
|
||||
public HotKeyDefaultSettings HotKey { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WebServerSettings
|
||||
{
|
||||
[JsonPropertyName("Port")]
|
||||
public int Port { get; set; } = 5057;
|
||||
|
||||
[JsonPropertyName("IsUsingStatic")]
|
||||
public bool IsUsingStatic { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("ConnectionString")]
|
||||
public string ConnectionString { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("HostUrl")]
|
||||
public string HostUrl { get; set; } = "http://localhost:5057";
|
||||
|
||||
[JsonPropertyName("ForEndUrl")]
|
||||
public string ForEndUrl { get; set; } = "http://localhost:5174";
|
||||
}
|
||||
|
||||
public class HotKeyDefaultSettings
|
||||
{
|
||||
[JsonPropertyName("DefaultModifiers")]
|
||||
public string DefaultModifiers { get; set; } = "Alt";
|
||||
|
||||
[JsonPropertyName("DefaultKey")]
|
||||
public string DefaultKey { get; set; } = "X";
|
||||
|
||||
[JsonPropertyName("DefaultIsEnabled")]
|
||||
public bool DefaultIsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 热键配置模型。
|
||||
/// 用于描述全局热键的修饰键、主键与启用状态,并通过通知机制驱动动态更新。
|
||||
/// </summary>
|
||||
public sealed class HotKeyConfig : INotifyPropertyChanged
|
||||
{
|
||||
private string _modifiers = "Alt";
|
||||
private string _key = "X";
|
||||
private bool _isEnabled = true;
|
||||
|
||||
/// <summary>
|
||||
/// 修饰键(例如 Alt、Control、Shift、Windows;多个键用逗号分隔)。
|
||||
/// </summary>
|
||||
public string Modifiers
|
||||
{
|
||||
get => _modifiers;
|
||||
set
|
||||
{
|
||||
if (_modifiers != value)
|
||||
{
|
||||
_modifiers = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主键(例如 X、C、V)。
|
||||
/// </summary>
|
||||
public string Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
if (_key != value)
|
||||
{
|
||||
_key = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用热键。
|
||||
/// </summary>
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set
|
||||
{
|
||||
if (_isEnabled != value)
|
||||
{
|
||||
_isEnabled = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.UseDesktopWebView();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 应用元信息工具类。
|
||||
/// 负责生成窗口标题与托盘提示文本等展示字符串,避免在多处重复拼装并保持跨宿主一致性。
|
||||
/// </summary>
|
||||
public static class AppMetadata
|
||||
{
|
||||
private const string AppNameText = "待办事项";
|
||||
|
||||
/// <summary>
|
||||
/// 应用名称(不含版本)。
|
||||
/// </summary>
|
||||
public static string AppName => AppNameText;
|
||||
|
||||
/// <summary>
|
||||
/// 获取显示版本号(主版本.次版本.修订版本)。
|
||||
/// 若无法解析版本信息则返回 null。
|
||||
/// </summary>
|
||||
public static string? GetDisplayVersion()
|
||||
{
|
||||
var asmVersion = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
if (asmVersion == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取托盘/关于等场景使用的显示标题(可能包含版本)。
|
||||
/// </summary>
|
||||
public static string GetDisplayTitle()
|
||||
{
|
||||
var version = GetDisplayVersion();
|
||||
return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取窗口标题栏文本(可能包含版本)。
|
||||
/// </summary>
|
||||
public static string GetWindowTitle()
|
||||
{
|
||||
var version = GetDisplayVersion();
|
||||
return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取托盘 Tooltip 文本(部分平台对长度有限制)。
|
||||
/// </summary>
|
||||
public static string GetTrayTooltipText()
|
||||
{
|
||||
var text = GetDisplayTitle();
|
||||
return text.Length > 63 ? text[..63] : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Hua.Todo.Application;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Hua.Todo.Application.DynamicApi;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 嵌入式 Web 服务器实现
|
||||
/// </summary>
|
||||
public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
private WebApplication? _webApp;
|
||||
private readonly AppSettings _appSettings;
|
||||
|
||||
/// <summary>
|
||||
/// 服务器是否正在运行
|
||||
/// </summary>
|
||||
public bool IsRunning => _webApp != null;
|
||||
|
||||
/// <summary>
|
||||
/// 服务器基础 URL
|
||||
/// </summary>
|
||||
public string BaseUrl => _appSettings.WebServer.HostUrl;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化嵌入式 Web 服务器服务
|
||||
/// </summary>
|
||||
/// <param name="appSettings">应用程序配置</param>
|
||||
public EmbeddedWebServerService(AppSettings appSettings)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步启动服务器
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_webApp != null) return;
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
|
||||
builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl);
|
||||
|
||||
// 配置控制器和 JSON 选项
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly)
|
||||
.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();
|
||||
|
||||
InitializeDatabase(app);
|
||||
|
||||
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
|
||||
if (_appSettings.WebServer.IsUsingStatic)
|
||||
{
|
||||
ServeStaticFiles(app);
|
||||
}
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
app.UseAuthorization();
|
||||
app.UseDynamicApi();
|
||||
app.MapControllers();
|
||||
|
||||
_webApp = app;
|
||||
|
||||
await _webApp.StartAsync();
|
||||
}
|
||||
|
||||
private void InitializeDatabase(WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
|
||||
try
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
|
||||
|
||||
var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString);
|
||||
var actualDbPath = sqliteBuilder.DataSource;
|
||||
if (!string.IsNullOrEmpty(actualDbPath))
|
||||
{
|
||||
var dbDir = Path.GetDirectoryName(actualDbPath);
|
||||
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
|
||||
{
|
||||
Directory.CreateDirectory(dbDir);
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
|
||||
dbContext.Database.Migrate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[EmbeddedWebServer] Database initialization failed: {ex.Message}");
|
||||
|
||||
try
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
|
||||
dbContext.Database.EnsureCreated();
|
||||
}
|
||||
catch (Exception ensureEx)
|
||||
{
|
||||
Console.WriteLine($"[EmbeddedWebServer] Database ensure-created failed: {ensureEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置静态文件服务(用于托管 Vue 前端)
|
||||
/// </summary>
|
||||
/// <param name="app">Web 应用程序实例</param>
|
||||
private void ServeStaticFiles(WebApplication app)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
if (!Directory.Exists(wwwrootPath))
|
||||
{
|
||||
Console.WriteLine($"[EmbeddedWebServer] wwwroot directory not found at: {wwwrootPath}. Static file serving disabled.");
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
// 处理 SPA 路由
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步停止服务器
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_webApp == null) return;
|
||||
|
||||
await _webApp.StopAsync();
|
||||
await _webApp.DisposeAsync();
|
||||
_webApp = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Hua.Todo.Avalonia.Services.Platforms;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 全局热键服务工厂。
|
||||
/// 通过运行时平台判断返回最合适的实现,避免在同一文件内混写大量条件编译代码。
|
||||
/// </summary>
|
||||
public static class GlobalHotKeyServiceFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建平台对应的全局热键服务实现。
|
||||
/// </summary>
|
||||
public static IGlobalHotKeyService Create()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WindowsGlobalHotKeyService();
|
||||
}
|
||||
|
||||
return new NoopGlobalHotKeyService();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 热键设置服务接口。
|
||||
/// 负责读取/保存用户可配置的全局热键,并提供从默认值重置的能力。
|
||||
/// </summary>
|
||||
public interface IHotKeySettingsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前热键配置(若尚未持久化则返回默认配置)。
|
||||
/// </summary>
|
||||
HotKeyConfig GetConfig();
|
||||
|
||||
/// <summary>
|
||||
/// 保存热键配置。
|
||||
/// </summary>
|
||||
/// <param name="config">要保存的热键配置。</param>
|
||||
void SaveConfig(HotKeyConfig config);
|
||||
|
||||
/// <summary>
|
||||
/// 重置为默认配置并落盘。
|
||||
/// </summary>
|
||||
void ResetToDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 热键设置服务实现。
|
||||
/// Avalonia 侧使用本地 JSON 文件持久化配置,以保证桌面端与多平台运行时一致性。
|
||||
/// </summary>
|
||||
public sealed class HotKeySettingsService : IHotKeySettingsService
|
||||
{
|
||||
private const string SettingsFileName = "hotkey.json";
|
||||
private readonly AppSettings _appSettings;
|
||||
private HotKeyConfig? _cached;
|
||||
|
||||
/// <summary>
|
||||
/// 创建 <see cref="HotKeySettingsService"/>。
|
||||
/// </summary>
|
||||
/// <param name="appSettings">应用配置(用于提供默认热键)。</param>
|
||||
public HotKeySettingsService(AppSettings appSettings)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HotKeyConfig GetConfig()
|
||||
{
|
||||
if (_cached != null)
|
||||
{
|
||||
return _cached;
|
||||
}
|
||||
|
||||
var path = GetSettingsPath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_cached = GetDefaultConfig();
|
||||
return _cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
_cached = JsonSerializer.Deserialize<HotKeyConfig>(json) ?? GetDefaultConfig();
|
||||
return _cached;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_cached = GetDefaultConfig();
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveConfig(HotKeyConfig config)
|
||||
{
|
||||
var path = GetSettingsPath();
|
||||
EnsureSettingsDirectory(path);
|
||||
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
File.WriteAllText(path, json);
|
||||
|
||||
_cached ??= config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetToDefault()
|
||||
{
|
||||
var defaultConfig = GetDefaultConfig();
|
||||
var current = GetConfig();
|
||||
|
||||
current.Modifiers = defaultConfig.Modifiers;
|
||||
current.Key = defaultConfig.Key;
|
||||
current.IsEnabled = defaultConfig.IsEnabled;
|
||||
|
||||
SaveConfig(current);
|
||||
}
|
||||
|
||||
private HotKeyConfig GetDefaultConfig()
|
||||
{
|
||||
return new HotKeyConfig
|
||||
{
|
||||
Modifiers = _appSettings.HotKey.DefaultModifiers,
|
||||
Key = _appSettings.HotKey.DefaultKey,
|
||||
IsEnabled = _appSettings.HotKey.DefaultIsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSettingsPath()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dir = Path.Combine(localAppData, "Hua.Todo");
|
||||
return Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
private static void EnsureSettingsDirectory(string settingsPath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(settingsPath);
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 嵌入式 Web 服务器接口
|
||||
/// </summary>
|
||||
public interface IEmbeddedWebServerService
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务器是否正在运行
|
||||
/// </summary>
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务器基础 URL
|
||||
/// </summary>
|
||||
string BaseUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 异步启动服务器
|
||||
/// </summary>
|
||||
Task StartAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 异步停止服务器
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 全局热键服务接口。
|
||||
/// 用于在桌面环境下注册系统级快捷键,以便在应用隐藏/最小化时快速唤起主窗口。
|
||||
/// </summary>
|
||||
public interface IGlobalHotKeyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册全局热键。
|
||||
/// </summary>
|
||||
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
|
||||
/// <param name="key">主键(如 X、C)。</param>
|
||||
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</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,29 @@
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 不支持全局热键的平台默认实现。
|
||||
/// 该实现不会注册任何系统级热键,用于保持上层逻辑简洁一致。
|
||||
/// </summary>
|
||||
public sealed class NoopGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Hua.Todo.Avalonia.ViewModels;
|
||||
|
||||
namespace Hua.Todo.Avalonia;
|
||||
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 应用托盘交互的 ViewModel。
|
||||
/// 用于为 <see cref="global::Avalonia.Controls.TrayIcon"/> 提供命令与提示文本,并将托盘事件路由到应用层逻辑。
|
||||
/// </summary>
|
||||
public sealed partial class AppTrayViewModel : ObservableObject
|
||||
{
|
||||
private readonly Action _showMainWindow;
|
||||
private readonly Action _exitApplication;
|
||||
|
||||
/// <summary>
|
||||
/// 创建 <see cref="AppTrayViewModel"/>。
|
||||
/// </summary>
|
||||
/// <param name="trayTooltipText">托盘提示文本(部分平台有长度限制)。</param>
|
||||
/// <param name="showMainWindow">显示/激活主窗口回调。</param>
|
||||
/// <param name="exitApplication">退出应用回调(应触发应用生命周期关闭)。</param>
|
||||
public AppTrayViewModel(string trayTooltipText, Action showMainWindow, Action exitApplication)
|
||||
{
|
||||
TrayTooltipText = trayTooltipText;
|
||||
_showMainWindow = showMainWindow;
|
||||
_exitApplication = exitApplication;
|
||||
|
||||
ShowMainWindowCommand = new RelayCommand(_showMainWindow);
|
||||
ExitApplicationCommand = new RelayCommand(_exitApplication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 托盘提示文本。
|
||||
/// </summary>
|
||||
public string TrayTooltipText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示/激活主窗口命令。
|
||||
/// </summary>
|
||||
public IRelayCommand ShowMainWindowCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 退出应用命令。
|
||||
/// </summary>
|
||||
public IRelayCommand ExitApplicationCommand { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Hua.Todo.Avalonia.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
public string Greeting { get; } = "Welcome to Avalonia!";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Hua.Todo.Avalonia.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
|
||||
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="450"
|
||||
Height="640"
|
||||
Title="待办事项"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<WebView Name="MainWebView" />
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,106 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using Hua.Todo.Avalonia.Services;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主窗口。
|
||||
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly AppSettings _appSettings;
|
||||
private readonly IEmbeddedWebServerService _webServer;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于设计器预览的主窗口实例。
|
||||
/// </summary>
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_appSettings = new AppSettings();
|
||||
_webServer = new EmbeddedWebServerService(_appSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建运行时主窗口实例。
|
||||
/// </summary>
|
||||
/// <param name="appSettings">应用配置。</param>
|
||||
/// <param name="webServer">嵌入式 WebServer。</param>
|
||||
public MainWindow(AppSettings appSettings, IEmbeddedWebServerService webServer)
|
||||
{
|
||||
InitializeComponent();
|
||||
_appSettings = appSettings;
|
||||
_webServer = webServer;
|
||||
|
||||
SetupWebView();
|
||||
}
|
||||
|
||||
private void SetupWebView()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_appSettings.WebServer.IsUsingStatic)
|
||||
{
|
||||
MainWebView.Url = new Uri(_webServer.BaseUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
MainWebView.Url = new Uri(_appSettings.WebServer.ForEndUrl);
|
||||
}
|
||||
|
||||
MainWebView.NavigationCompleted += async (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_webServer.IsRunning)
|
||||
{
|
||||
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
|
||||
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
|
||||
}
|
||||
|
||||
await MainWebView.ExecuteScriptAsync(@"
|
||||
window.mauiInterop = {
|
||||
onHotKeyConfigUpdated: null,
|
||||
openHotKeySettings: function(config) {
|
||||
const event = new CustomEvent('openHotKeySettings', { detail: config });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
updateHotKeyConfig: function(modifiers, key, isEnabled) {
|
||||
const event = new CustomEvent('updateHotKeyConfig', {
|
||||
detail: { modifiers, key, isEnabled }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hotKeyConfigChanged', function(e) {
|
||||
if (window.mauiInterop.onHotKeyConfigUpdated) {
|
||||
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
|
||||
}
|
||||
});
|
||||
");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 Runtime;Linux 需要 WebKitGTK)。",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Hua.Todo.Avalonia.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"WebServer": {
|
||||
"Port": 5057,
|
||||
"IsUsingStatic": false,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
},
|
||||
"HotKey": {
|
||||
"DefaultModifiers": "Alt",
|
||||
"DefaultKey": "X",
|
||||
"DefaultIsEnabled": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user