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
+33
View File
@@ -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>
+270
View File
@@ -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));
}
}
+23
View File
@@ -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;
}
}
}
+37
View File
@@ -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 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
}
}
+18
View File
@@ -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>
+14
View File
@@ -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
}
}