1.更换软件协议为AGPL

2.切换项目名称为Hua.Todo
This commit is contained in:
ShaoHua
2026-04-06 22:06:30 +08:00
parent 40a91e39b6
commit 758f6772c6
147 changed files with 1203 additions and 644 deletions
+14
View File
@@ -0,0 +1,14 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Hua.Todo.Maui"
x:Class="Hua.Todo.Maui.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+293
View File
@@ -0,0 +1,293 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.IO;
using Hua.Todo.Maui.Services;
using Hua.Todo.Maui.Views;
using Hua.Todo.Maui.Models;
#if WINDOWS
using System.Runtime.InteropServices;
using Windowing = Microsoft.UI.Windowing;
using WinUiWindow = Microsoft.UI.Xaml.Window;
using WinRT.Interop;
#endif
namespace Hua.Todo.Maui;
public partial class App : global::Microsoft.Maui.Controls.Application
{
private readonly IServiceProvider _serviceProvider;
private readonly IHotKeySettingsService _settingsService;
private readonly IGlobalHotKeyService _hotKeyService;
private readonly ISystemTrayService _trayService;
private Window? _mainWindow;
private bool _isHotkeyRegistered;
private bool _isWindowCentered;
public App(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
InitializeComponent();
_settingsService = serviceProvider.GetRequiredService<IHotKeySettingsService>();
_hotKeyService = serviceProvider.GetRequiredService<IGlobalHotKeyService>();
_trayService = serviceProvider.GetRequiredService<ISystemTrayService>();
}
protected override Microsoft.Maui.Controls.Window CreateWindow(IActivationState? activationState)
{
_mainWindow = new Microsoft.Maui.Controls.Window(_serviceProvider.GetRequiredService<MainPage>())
{
Width = 450,
Height = 640,
Title = AppMetadata.GetWindowTitle()
};
#if WINDOWS
_mainWindow.TitleBar = CreateWindowTitleBar();
#endif
_mainWindow.Destroying += (s, e) =>
{
if (_isHotkeyRegistered)
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
_trayService.Dispose();
};
_mainWindow.Created += (s, e) =>
{
_trayService.Initialize(_mainWindow, ShowMainWindow, ExitApplication);
RegisterHotkeyWhenReady();
#if WINDOWS
MainThread.BeginInvokeOnMainThread(() =>
{
if (_mainWindow.Handler?.PlatformView is WinUiWindow platformWindow)
{
// Ensure app doesn't shutdown when main window closes (we hide it)
platformWindow.AppWindow.Closing += (sender, args) =>
{
args.Cancel = true;
new Hua.Todo.Maui.Platforms.Windows.WindowsWindowService().HideWindow(_mainWindow);
};
CenterMainWindow(platformWindow);
ConfigureWindowsTitleBar(platformWindow);
}
});
#endif
};
return _mainWindow;
}
private void RegisterHotkeyWhenReady(int attempt = 0)
{
if (_mainWindow == null) return;
#if WINDOWS
if (_mainWindow.Handler?.PlatformView is not WinUiWindow)
{
if (attempt < 30)
{
_mainWindow.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(100), () => RegisterHotkeyWhenReady(attempt + 1));
}
return;
}
#endif
RegisterHotkey();
}
private void OnHotKeyPressed()
{
MainThread.BeginInvokeOnMainThread(() =>
{
ShowMainWindow();
});
}
private void ShowMainWindow()
{
if (_mainWindow != null)
{
_mainWindow.Dispatcher.Dispatch(() =>
{
#if WINDOWS
if (_mainWindow.Handler != null)
{
new Hua.Todo.Maui.Platforms.Windows.WindowsWindowService().RestoreWindow(_mainWindow);
var platformWindow = _mainWindow.Handler.PlatformView as WinUiWindow;
platformWindow?.Activate();
}
#else
if (global::Microsoft.Maui.Controls.Application.Current != null &&
!global::Microsoft.Maui.Controls.Application.Current.Windows.Contains(_mainWindow))
{
global::Microsoft.Maui.Controls.Application.Current.OpenWindow(_mainWindow);
}
#endif
});
}
}
private void ExitApplication()
{
_trayService?.Dispose();
global::Microsoft.Maui.Controls.Application.Current?.Quit();
}
private void RegisterHotkey()
{
if (_hotKeyService == null || _settingsService == null) return;
var config = _settingsService.GetConfig();
if (_hotKeyService.IsSupported && config.IsEnabled)
{
_hotKeyService.RegisterHotKey(config.Modifiers, config.Key, OnHotKeyPressed);
_isHotkeyRegistered = true;
config.PropertyChanged += (s, args) =>
{
if (args.PropertyName == nameof(HotKeyConfig.Modifiers) ||
args.PropertyName == nameof(HotKeyConfig.Key) ||
args.PropertyName == nameof(HotKeyConfig.IsEnabled))
{
if (config.IsEnabled && _hotKeyService.IsSupported)
{
_hotKeyService.UpdateHotKey(config.Modifiers, config.Key);
}
else
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
}
};
}
}
#if WINDOWS
private void ConfigureWindowsTitleBar(WinUiWindow platformWindow)
{
var title = AppMetadata.GetWindowTitle();
platformWindow.Title = title;
if (_mainWindow != null)
{
_mainWindow.Title = title;
}
var appWindow = platformWindow.AppWindow;
if (appWindow != null)
{
appWindow.Title = title;
var hWnd = WindowNative.GetWindowHandle(platformWindow);
if (hWnd != IntPtr.Zero)
{
SetWindowText(hWnd, title);
}
var iconPath = Path.Combine(AppContext.BaseDirectory, "icon.ico");
if (File.Exists(iconPath))
{
appWindow.SetIcon(iconPath);
}
var titleBar = appWindow.TitleBar;
titleBar.IconShowOptions = Windowing.IconShowOptions.ShowIconAndSystemMenu;
titleBar.BackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.InactiveBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonInactiveBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonHoverBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonPressedBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonInactiveForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonHoverForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonPressedForegroundColor = Microsoft.UI.Colors.Black;
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "SetWindowTextW")]
private static extern bool SetWindowText(IntPtr hWnd, string lpString);
private static TitleBar CreateWindowTitleBar()
{
return new TitleBar
{
BackgroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#F5F5F5"),
Icon = string.Empty,
Title = string.Empty,
ForegroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"),
LeadingContent = new HorizontalStackLayout
{
Spacing = 4,
Padding = new Microsoft.Maui.Thickness(10, 0, 0, 0),
VerticalOptions = LayoutOptions.Center,
Children =
{
new Image
{
Source = "icon.jpg",
WidthRequest = 22,
HeightRequest = 22,
VerticalOptions = LayoutOptions.Center
},
new Label
{
Text = AppMetadata.GetTitleBarVersionText(),
FontFamily = "Microsoft YaHei UI",
TextColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"),
VerticalTextAlignment = Microsoft.Maui.TextAlignment.Center,
VerticalOptions = LayoutOptions.Center,
FontSize = 14
}
}
}
};
}
private void CenterMainWindow(WinUiWindow platformWindow)
{
if (_isWindowCentered) return;
var appWindow = platformWindow.AppWindow;
if (appWindow == null) return;
var displayArea = Windowing.DisplayArea.GetFromWindowId(
appWindow.Id,
Windowing.DisplayAreaFallback.Primary);
var workArea = displayArea.WorkArea;
var windowWidthPx = appWindow.Size.Width;
var windowHeightPx = appWindow.Size.Height;
if (windowWidthPx <= 0 || windowHeightPx <= 0)
{
var scale = platformWindow.Content?.XamlRoot?.RasterizationScale ?? 1.0;
windowWidthPx = windowWidthPx <= 0 ? (int)Math.Round((_mainWindow?.Width ?? 450) * scale) : windowWidthPx;
windowHeightPx = windowHeightPx <= 0 ? (int)Math.Round((_mainWindow?.Height ?? 640) * scale) : windowHeightPx;
}
if (windowWidthPx <= 0 || windowHeightPx <= 0) return;
var x = workArea.X + (workArea.Width - windowWidthPx) / 2;
var y = workArea.Y + (workArea.Height - windowHeightPx) / 2;
if (windowWidthPx >= workArea.Width) x = workArea.X;
if (windowHeightPx >= workArea.Height) y = workArea.Y;
x = Math.Max(workArea.X, Math.Min(x, workArea.X + workArea.Width - windowWidthPx));
y = Math.Max(workArea.Y, Math.Min(y, workArea.Y + workArea.Height - windowHeightPx));
appWindow.Move(new Windows.Graphics.PointInt32(x, y));
_isWindowCentered = true;
}
#endif
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="Hua.Todo.Maui.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Hua.Todo.Maui"
xmlns:views="clr-namespace:Hua.Todo.Maui.Views"
Title="Hua.Todo.Maui">
<ShellContent
Title="Home"
Route="MainPage" />
</Shell>
+9
View File
@@ -0,0 +1,9 @@
namespace Hua.Todo.Maui;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}
@@ -0,0 +1,28 @@
using Microsoft.Maui.Controls;
using System.Globalization;
namespace Hua.Todo.Maui.Converters
{
public class PriorityToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string priority)
{
return priority switch
{
"高" => Color.FromRgb(255, 205, 210),
"中" => Color.FromRgb(255, 224, 178),
"低" => Color.FromRgb(200, 230, 201),
_ => Colors.White
};
}
return Colors.White;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
+191
View File
@@ -0,0 +1,191 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>Hua.Todo.Maui</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CodePage>65001</CodePage>
<!-- Android SDK Path - Fallback for local development -->
<AndroidSdkDirectory Condition="'$(AndroidSdkDirectory)' == '' and Exists('$(USERPROFILE)\AppData\Local\Android\Sdk')">$(USERPROFILE)\AppData\Local\Android\Sdk</AndroidSdkDirectory>
<!-- Display name -->
<ApplicationTitle>代办</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<Version>1.1.5</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Assembly Info -->
<AssemblyTitle>待办</AssemblyTitle>
<AssemblyProduct>待办</AssemblyProduct>
<AssemblyCompany>Hua.Todo</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<TodoWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</TodoWebDir>
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>True</AndroidUseAapt2>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<Optimize>False</Optimize>
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
<DisableResizetizer>true</DisableResizetizer>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<MauiAsset Include="appsettings.json" LogicalName="appsettings.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="icon.ico" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;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="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoWebDistDir)\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(MSBuildProjectDirectory)\wwwroot" Condition="Exists('$(MSBuildProjectDirectory)\wwwroot')" />
<MakeDir Directories="$(MSBuildProjectDirectory)\wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(MSBuildProjectDirectory)\wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<ItemGroup>
<MauiAsset Include="@(_TodoWebDistFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
<Target Name="CopyTodoWebDistToWindowsWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And 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,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=wwwroot/@EntryIndexedValue">False</s:Boolean></wpf:ResourceDictionary>
+167
View File
@@ -0,0 +1,167 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using Hua.Todo.Application;
using Hua.Todo.Application.Data;
using Hua.Todo.Maui.Models;
using Hua.Todo.Maui.Services;
using Hua.Todo.Maui.Services.Platforms;
namespace Hua.Todo.Maui;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
var appSettings = LoadAppSettings();
// Set default connection string if not provided
if (string.IsNullOrEmpty(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";
}
builder.Services.AddSingleton(appSettings);
var connectionString = appSettings.WebServer.ConnectionString;
builder.Services.AddApplicationServices(connectionString);
builder.Services.AddTransient<Views.MainPage>();
builder.Services.AddSingleton<IHotKeySettingsService>(sp =>
new HotKeySettingsService(sp.GetRequiredService<AppSettings>()));
builder.Services.AddSingleton<IGlobalHotKeyService>(sp => GlobalHotKeyServiceFactory.Create());
builder.Services.AddSingleton<ISystemTrayService>(sp =>
{
#if WINDOWS
return new WindowsSystemTrayService();
#else
return new NullSystemTrayService();
#endif
});
#if WINDOWS
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
#elif ANDROID
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
#else
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
#endif
#if DEBUG
builder.Logging.AddDebug();
#endif
var app = builder.Build();
_ = Task.Run(async () =>
{
try
{
InitializeDatabase(app.Services, connectionString);
await StartWebServer(app.Services);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"App initialization failed: {ex}");
}
});
return app;
}
private static AppSettings LoadAppSettings()
{
try
{
using var stream = FileSystem.OpenAppPackageFileAsync("appsettings.json").GetAwaiter().GetResult();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
try
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(settingsPath))
{
return new AppSettings();
}
var json = File.ReadAllText(settingsPath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
}
private static void InitializeDatabase(IServiceProvider services, string connectionString)
{
using var scope = services.CreateScope();
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString);
var actualDbPath = sqliteBuilder.DataSource;
if (!string.IsNullOrEmpty(actualDbPath))
{
var dbDir = Path.GetDirectoryName(actualDbPath);
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
}
// Ensure WAL mode to avoid locking issues
dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
dbContext.Database.Migrate();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}");
try
{
var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
context.Database.EnsureCreated();
}
catch
{
}
}
}
private static async Task StartWebServer(IServiceProvider services)
{
try
{
var webServer = services.GetRequiredService<IEmbeddedWebServerService>();
await webServer.StartAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Maui.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;
}
+70
View File
@@ -0,0 +1,70 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Hua.Todo.Maui.Models
{
/// <summary>
/// 热键配置模型类
/// </summary>
public class HotKeyConfig : INotifyPropertyChanged
{
private string _modifiers = "Alt";
private string _key = "X";
private bool _isEnabled = true;
/// <summary>
/// 修饰键(如 Alt, Control, Shift 等)
/// </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();
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Maui.Models
{
public class QuickEntryData
{
[JsonPropertyName("action")]
public string Action { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("priority")]
public string Priority { get; set; } = "Medium";
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Hua.Todo.Maui.Models
{
/// <summary>
/// 代办模型类
/// </summary>
public class TodoItem
{
/// <summary>
/// 任务内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 任务优先级
/// </summary>
public string Priority { get; set; } = "中";
/// <summary>
/// 任务是否已完成
/// </summary>
public bool IsCompleted { get; set; }
}
}
@@ -0,0 +1,135 @@
using Android.Content.Res;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
namespace Hua.Todo.Maui.Platforms.Android;
public sealed class AndroidAssetFileProvider : IFileProvider
{
private readonly AssetManager _assets;
private readonly string _root;
public AndroidAssetFileProvider(AssetManager assets, string root)
{
_assets = assets;
_root = NormalizePath(root).TrimEnd('/');
}
public IFileInfo GetFileInfo(string subpath)
{
var assetPath = Combine(_root, subpath);
if (string.IsNullOrEmpty(assetPath))
{
return new NotFoundFileInfo(subpath);
}
try
{
using var stream = _assets.Open(assetPath);
return new AndroidAssetFileInfo(_assets, assetPath, Path.GetFileName(assetPath), false);
}
catch (global::Java.IO.FileNotFoundException)
{
return new NotFoundFileInfo(subpath);
}
catch
{
return new NotFoundFileInfo(subpath);
}
}
public IDirectoryContents GetDirectoryContents(string subpath)
{
var dirPath = Combine(_root, subpath).TrimEnd('/');
if (string.IsNullOrEmpty(dirPath))
{
return NotFoundDirectoryContents.Singleton;
}
try
{
var entries = _assets.List(dirPath) ?? Array.Empty<string>();
return new AndroidAssetDirectoryContents(_assets, dirPath, entries);
}
catch
{
return NotFoundDirectoryContents.Singleton;
}
}
public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
private static string Combine(string root, string subpath)
{
var cleanSub = NormalizePath(subpath).TrimStart('/');
if (string.IsNullOrEmpty(root)) return cleanSub;
if (string.IsNullOrEmpty(cleanSub)) return root;
return $"{root}/{cleanSub}";
}
private static string NormalizePath(string path) => (path ?? string.Empty).Replace('\\', '/');
private sealed class AndroidAssetFileInfo : IFileInfo
{
private readonly AssetManager _assets;
private readonly string _assetPath;
public AndroidAssetFileInfo(AssetManager assets, string assetPath, string name, bool isDirectory)
{
_assets = assets;
_assetPath = assetPath;
Name = name;
IsDirectory = isDirectory;
}
public bool Exists => true;
public long Length => -1;
public string? PhysicalPath => null;
public string Name { get; }
public DateTimeOffset LastModified => DateTimeOffset.MinValue;
public bool IsDirectory { get; }
public Stream CreateReadStream() => _assets.Open(_assetPath);
}
private sealed class AndroidAssetDirectoryContents : IDirectoryContents
{
private readonly AssetManager _assets;
private readonly string _dirPath;
private readonly string[] _entries;
public AndroidAssetDirectoryContents(AssetManager assets, string dirPath, string[] entries)
{
_assets = assets;
_dirPath = dirPath;
_entries = entries;
}
public bool Exists => true;
public IEnumerator<IFileInfo> GetEnumerator()
{
foreach (var entry in _entries)
{
if (string.IsNullOrEmpty(entry)) continue;
var childPath = $"{_dirPath}/{entry}";
var childList = Array.Empty<string>();
var isDir = false;
try
{
childList = _assets.List(childPath) ?? Array.Empty<string>();
isDir = childList.Length > 0;
}
catch
{
}
yield return new AndroidAssetFileInfo(_assets, childPath, entry, isDir);
}
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,18 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace Hua.Todo.Maui;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
#if DEBUG
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
#endif
}
}
@@ -0,0 +1,15 @@
using Android.App;
using Android.Runtime;
namespace Hua.Todo.Maui;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,464 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Maui.Models;
namespace Hua.Todo.Maui.Services;
public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
{
private readonly AppSettings _appSettings;
private readonly IServiceProvider _services;
private TcpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _acceptLoop;
public bool IsRunning => _listener != null;
public string BaseUrl => $"http://localhost:{_appSettings.WebServer.Port}";
public MobileEmbeddedWebServerService(AppSettings appSettings, IServiceProvider services)
{
_appSettings = appSettings;
_services = services;
}
public Task StartAsync()
{
if (_listener != null) return Task.CompletedTask;
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
_listener.Start();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
return Task.CompletedTask;
}
public async Task StopAsync()
{
if (_listener == null) return;
try
{
_cts?.Cancel();
}
catch
{
}
try
{
_listener.Stop();
}
catch
{
}
try
{
if (_acceptLoop != null) await _acceptLoop;
}
catch
{
}
_listener = null;
_acceptLoop = null;
_cts?.Dispose();
_cts = null;
}
private async Task AcceptLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested && _listener != null)
{
TcpClient? client = null;
try
{
client = await _listener.AcceptTcpClientAsync(token);
_ = Task.Run(() => HandleClientAsync(client, token), token);
}
catch
{
client?.Dispose();
}
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
{
using var _ = client;
using var stream = client.GetStream();
HttpRequestData request;
try
{
request = await ReadRequestAsync(stream, token);
}
catch
{
return;
}
if (request.Path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await HandleApiAsync(stream, request, token);
return;
}
await HandleStaticAsync(stream, request, token);
}
private async Task HandleApiAsync(Stream stream, HttpRequestData request, CancellationToken token)
{
try
{
var path = request.Path;
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var serviceName = segments[1];
if (!serviceName.Equals("task", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
using var scope = _services.CreateScope();
var taskService = scope.ServiceProvider.GetRequiredService<ITaskService>();
object? result = null;
var methodName = string.Empty;
var tail = segments.Skip(2).ToArray();
if (request.Method == "GET" && tail.Length == 0)
{
methodName = nameof(ITaskService.GetAllTasksAsync);
result = await taskService.GetAllTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("active", StringComparison.OrdinalIgnoreCase))
{
methodName = nameof(ITaskService.GetActiveTasksAsync);
result = await taskService.GetActiveTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("completed", StringComparison.OrdinalIgnoreCase))
{
methodName = nameof(ITaskService.GetCompletedTasksAsync);
result = await taskService.GetCompletedTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && int.TryParse(tail[0], out var getId))
{
methodName = nameof(ITaskService.GetTaskByIdAsync);
result = await taskService.GetTaskByIdAsync(getId);
}
else if (request.Method == "GET" && tail.Length == 2 && tail[1].Equals("subtasks", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var parentId))
{
methodName = nameof(ITaskService.GetSubTasksAsync);
result = await taskService.GetSubTasksAsync(parentId);
}
else if (request.Method == "POST" && tail.Length == 0)
{
methodName = nameof(ITaskService.CreateTaskAsync);
var dto = DeserializeBody<CreateTaskDto>(request.Body);
result = await taskService.CreateTaskAsync(dto);
}
else if (request.Method == "PUT" && tail.Length == 0)
{
methodName = nameof(ITaskService.UpdateTaskAsync);
var dto = DeserializeBody<UpdateTaskDto>(request.Body);
result = await taskService.UpdateTaskAsync(dto);
}
else if (request.Method == "PATCH" && tail.Length == 2 && tail[1].Equals("toggle", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var toggleId))
{
methodName = nameof(ITaskService.ToggleCompleteAsync);
result = await taskService.ToggleCompleteAsync(toggleId);
}
else if (request.Method == "DELETE" && tail.Length == 1 && int.TryParse(tail[0], out var deleteId))
{
methodName = nameof(ITaskService.DeleteTaskAsync);
await taskService.DeleteTaskAsync(deleteId);
result = null;
}
else
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var response = CreateApiResponse(result, null, methodName);
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
}
catch (Exception ex)
{
var response = CreateApiResponse(null, ex, string.Empty);
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
}
}
private async Task HandleStaticAsync(Stream stream, HttpRequestData request, CancellationToken token)
{
var path = request.Path;
if (string.IsNullOrEmpty(path) || path == "/") path = "/index.html";
if (path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
{
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
var assetPath = $"wwwroot{path}";
if (path.Contains("..", StringComparison.Ordinal))
{
await WriteTextAsync(stream, 400, "Bad Request", "text/plain; charset=utf-8", token);
return;
}
if (!TryOpenAsset(assetPath, out var assetStream))
{
if (!Path.HasExtension(path))
{
assetPath = "wwwroot/index.html";
if (TryOpenAsset(assetPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
}
return;
}
}
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
}
}
private static bool TryOpenAsset(string assetPath, out Stream stream)
{
try
{
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
return true;
}
catch
{
stream = Stream.Null;
return false;
}
}
private static string GetContentType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".html" => "text/html; charset=utf-8",
".js" => "text/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".ico" => "image/x-icon",
".json" => "application/json; charset=utf-8",
".map" => "application/json; charset=utf-8",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream"
};
}
private static (int StatusCode, object Payload) CreateApiResponse(object? result, Exception? exception, string methodName)
{
var successMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功",
_ => "操作成功"
};
var failureMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败",
_ => "操作失败"
};
List<string>? errors = null;
if (exception != null)
{
errors = new List<string> { exception.Message };
}
var payload = new
{
Success = exception == null,
Data = exception == null ? result : null,
Message = exception == null ? successMessage : failureMessage,
Errors = errors
};
var statusCode = exception switch
{
KeyNotFoundException => 404,
ArgumentException => 400,
_ => 200
};
return (statusCode, payload);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
private static T DeserializeBody<T>(string body) where T : new()
{
if (string.IsNullOrWhiteSpace(body)) return new T();
return JsonSerializer.Deserialize<T>(body, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
}) ?? new T();
}
private static async Task WriteJsonAsync(Stream stream, int statusCode, object payload, CancellationToken token)
{
var json = JsonSerializer.Serialize(payload, JsonOptions);
await WriteTextAsync(stream, statusCode, json, "application/json; charset=utf-8", token);
}
private static async Task WriteTextAsync(Stream stream, int statusCode, string body, string contentType, CancellationToken token)
{
var bytes = Encoding.UTF8.GetBytes(body);
var header =
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
$"Content-Type: {contentType}\r\n" +
$"Content-Length: {bytes.Length}\r\n" +
$"Connection: close\r\n" +
$"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, token);
await stream.WriteAsync(bytes, token);
}
private static async Task WriteStreamAsync(Stream stream, int statusCode, Stream bodyStream, string contentType, CancellationToken token)
{
await using var ms = new MemoryStream();
await bodyStream.CopyToAsync(ms, token);
var bodyBytes = ms.ToArray();
var header =
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
$"Content-Type: {contentType}\r\n" +
$"Content-Length: {bodyBytes.Length}\r\n" +
$"Connection: close\r\n" +
$"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, token);
await stream.WriteAsync(bodyBytes, token);
}
private static async Task<HttpRequestData> ReadRequestAsync(NetworkStream stream, CancellationToken token)
{
using var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 8192, leaveOpen: true);
var requestLine = await reader.ReadLineAsync(token);
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
var parts = requestLine.Split(' ');
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
var method = parts[0].Trim().ToUpperInvariant();
var target = parts[1].Trim();
string path;
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
absoluteUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)))
{
path = absoluteUri.AbsolutePath;
}
else if (target.StartsWith("//", StringComparison.Ordinal) &&
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
{
path = authorityUri.AbsolutePath;
}
else
{
path = target;
var queryIndex = target.IndexOf('?', StringComparison.Ordinal);
if (queryIndex >= 0) path = target.Substring(0, queryIndex);
}
if (string.IsNullOrEmpty(path)) path = "/";
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
{
var idx = line.IndexOf(':');
if (idx <= 0) continue;
var name = line.Substring(0, idx).Trim();
var value = line.Substring(idx + 1).Trim();
headers[name] = value;
}
var body = string.Empty;
if (headers.TryGetValue("Content-Length", out var contentLengthStr) && int.TryParse(contentLengthStr, out var contentLength) && contentLength > 0)
{
var buffer = new char[contentLength];
var read = 0;
while (read < contentLength)
{
var n = await reader.ReadAsync(buffer, read, contentLength - read);
if (n <= 0) break;
read += n;
}
body = new string(buffer, 0, read);
}
return new HttpRequestData(method, path, body);
}
private static string GetReasonPhrase(int statusCode) => statusCode switch
{
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK"
};
private readonly record struct HttpRequestData(string Method, string Path, string Body);
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#512BD4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#512BD4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,9 @@
using Foundation;
namespace Hua.Todo.Maui;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Accessibility entitlement for global hotkey support -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.lifestyle</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace Hua.Todo.Maui;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="Hua.Todo.Maui.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:Hua.Todo.Maui.WinUI">
</maui:MauiWinUIApplication>
@@ -0,0 +1,24 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Hua.Todo.Maui.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="C9D66914-AC5B-46D6-BE4B-214C893F6CB9" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
</Capabilities>
</Package>
@@ -0,0 +1,129 @@
using System.Runtime.InteropServices;
namespace Hua.Todo.Maui.Platforms.Windows
{
public class WindowsKeyboardHandler : IDisposable
{
private KeyboardHook _keyboardHook;
private bool _isDisposed;
public event EventHandler? EscKeyPressed;
public WindowsKeyboardHandler()
{
_keyboardHook = new KeyboardHook();
_keyboardHook.KeyPressed += OnKeyPressed;
}
public void Start()
{
_keyboardHook.Hook();
}
private void OnKeyPressed(object? sender, KeyPressedEventArgs e)
{
if (e.Key == 0x1B && !e.IsKeyDown)
{
EscKeyPressed?.Invoke(this, EventArgs.Empty);
}
}
public void Dispose()
{
if (!_isDisposed)
{
_keyboardHook.Unhook();
_keyboardHook.KeyPressed -= OnKeyPressed;
_isDisposed = true;
}
}
}
internal class KeyboardHook : IDisposable
{
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
private const int WM_KEYUP = 0x0101;
private const int WM_SYSKEYDOWN = 0x0104;
private const int WM_SYSKEYUP = 0x0105;
private IntPtr _hookID = IntPtr.Zero;
private LowLevelKeyboardProc _proc;
private bool _isDisposed;
public event EventHandler<KeyPressedEventArgs>? KeyPressed;
public KeyboardHook()
{
_proc = HookCallback;
}
public void Hook()
{
_hookID = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle("user32"), 0);
}
public void Unhook()
{
if (_hookID != IntPtr.Zero)
{
UnhookWindowsHookEx(_hookID);
_hookID = IntPtr.Zero;
}
}
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
int vkCode = Marshal.ReadInt32(lParam);
bool isKeyDown = wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN;
bool isKeyUp = wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP;
if (isKeyDown || isKeyUp)
{
var args = new KeyPressedEventArgs(vkCode, isKeyDown);
KeyPressed?.Invoke(this, args);
}
}
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
public void Dispose()
{
if (!_isDisposed)
{
Unhook();
_isDisposed = true;
}
}
[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)]
[return: MarshalAs(UnmanagedType.Bool)]
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);
}
internal delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
internal class KeyPressedEventArgs : EventArgs
{
public int Key { get; }
public bool IsKeyDown { get; }
public KeyPressedEventArgs(int key, bool isKeyDown)
{
Key = key;
IsKeyDown = isKeyDown;
}
}
}
@@ -0,0 +1,68 @@
#if WINDOWS
using System.Runtime.InteropServices;
using Microsoft.Maui.Controls;
using Microsoft.UI.Windowing;
using WinRT.Interop;
namespace Hua.Todo.Maui.Platforms.Windows
{
public class WindowsWindowService
{
private const int SW_HIDE = 0;
private const int SW_SHOW = 5;
private const int SW_RESTORE = 9;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
public void HideWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var hWnd = WindowNative.GetWindowHandle(nativeWindow);
if (hWnd == IntPtr.Zero) return;
ShowWindow(hWnd, SW_HIDE);
}
public void RestoreWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var hWnd = WindowNative.GetWindowHandle(nativeWindow);
if (hWnd == IntPtr.Zero) return;
ShowWindow(hWnd, SW_SHOW);
ShowWindow(hWnd, SW_RESTORE);
}
public void MinimizeWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var appWindow = nativeWindow.AppWindow;
if (appWindow != null)
{
var overlappedPresenter = appWindow.Presenter as OverlappedPresenter;
if (overlappedPresenter != null)
{
overlappedPresenter.Minimize();
}
}
}
}
}
#endif
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Hua.Todo.Maui.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
@@ -0,0 +1,9 @@
using Foundation;
namespace Hua.Todo.Maui;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace Hua.Todo.Maui;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>
+149
View File
@@ -0,0 +1,149 @@
# Hua.Todo MAUI 跨平台快捷键功能
## 功能概述
本项目实现了基于 MAUI + WebView 架构的跨平台代办管理应用,支持全局快捷键快速唤醒功能。
## 支持的平台
- **Windows**: 完整支持,使用 Windows API 实现全局快捷键
- **macOS**: 完整支持,使用 AppKit API 实现全局快捷键
- **Android**: 基础支持,通过通知快捷方式实现
- **iOS**: 基础支持,通过通知快捷方式实现
## 核心功能
### 1. 全局快捷键
- **Windows 默认快捷键**: `Alt + X`
- **macOS 默认快捷键**: `Cmd + Option + X`
- **移动端**: 通过通知快捷方式实现
### 2. 快捷键设置
- 用户可以自定义快捷键组合
- 支持多种修饰键组合(Alt、Control、Shift 等)
- 支持禁用/启用快捷键功能
- 设置持久化保存
### 3. WebView 集成
- 主界面嵌入 WebView,显示 Hua.Todo Web 应用
- 快捷键按下时自动激活窗口并聚焦 WebView
- 支持与本地 API 服务通信
## 项目结构
```
Hua.Todo.Maui/
├── Models/
│ └── HotKeyConfig.cs # 快捷键配置模型
├── Services/
│ ├── IGlobalHotKeyService.cs # 快捷键服务接口
│ ├── HotKeySettingsService.cs # 设置存储服务
│ ├── GlobalHotKeyServiceFactory.cs # 平台工厂
│ └── Platforms/
│ ├── WindowsGlobalHotKeyService.cs # Windows 实现
│ ├── MacGlobalHotKeyService.cs # macOS 实现
│ └── MobileGlobalHotKeyService.cs # 移动端实现
├── Views/
│ ├── MainPage.xaml # 主页面(WebView
│ ├── MainPage.xaml.cs
│ ├── HotKeySettingsPage.xaml # 快捷键设置页面
│ └── HotKeySettingsPage.xaml.cs
├── App.xaml.cs # 应用入口
└── MauiProgram.cs # MAUI 配置
```
## 使用方法
### 1. 运行项目
#### Windows
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-windows10.0.19041.0
dotnet run -f net10.0-windows10.0.19041.0
```
#### macOS
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-maccatalyst
dotnet run -f net10.0-maccatalyst
```
#### Android
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-android
dotnet run -f net10.0-android
```
### 2. 使用快捷键
1. 启动应用后,默认快捷键为 `Alt + X`Windows)或 `Cmd + Option + X`macOS
2. 按下快捷键,应用窗口会自动激活并聚焦
3. 在 WebView 中可以直接输入任务内容
### 3. 自定义快捷键
1. 点击主界面右上角的"设置"按钮
2. 在设置页面中:
- 切换"启用快捷键"开关
- 选择想要的修饰键组合
- 选择主键
- 点击"保存设置"按钮
3. 设置会立即生效
### 4. 恢复默认设置
在设置页面点击"恢复默认"按钮,快捷键将恢复为默认配置。
## 技术实现细节
### Windows 平台
- 使用 `RegisterHotKey``UnregisterHotKey` Windows API
- 支持全局快捷键监听
- 通过 `WindowNative` 获取窗口句柄
### macOS 平台
- 使用 `NSEvent.AddGlobalMonitorForEventsMatchingMask` 监听全局事件
- 支持 Command、Option、Control、Shift 等修饰键
- 需要配置 `com.apple.security.automation.apple-events` 权限
### 移动端
- Android: 使用 `ShortcutManagerCompat` 创建通知快捷方式
- iOS: 使用 `UIApplicationShortcutItem` 实现快捷操作
### 数据持久化
- 使用 `Microsoft.Maui.Storage.Preferences` 存储配置
- JSON 序列化保存快捷键配置
- 支持重置为默认配置
## 依赖项
### NuGet 包
- `Microsoft.Maui.Controls`
- `Microsoft.Extensions.DependencyInjection`
- `Microsoft.Extensions.Logging.Debug`
### 平台特定
- Windows: `Microsoft.Windows.SDK.BuildTools`
- macOS: `Microsoft.Maui.Controls.Compatibility`
## 注意事项
1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限
2. **Windows UAC**: 某些情况下可能需要管理员权限
3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代
4. **WebView**: 确保 Hua.Todo.Api 服务在 `http://localhost:5173` 运行
## 后续计划
- [ ] 添加 Linux 平台支持
- [ ] 实现云同步功能
- [ ] 添加更多快捷键功能
- [ ] 优化性能和响应速度
- [ ] 添加快捷键冲突检测
## 许可证
AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN))
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" rx="88" ry="88" fill="#512BD4" />
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" fill="#ffffff" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" fill="#ffffff" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" fill="#ffffff" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>
@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>
+60
View File
@@ -0,0 +1,60 @@
using Microsoft.Maui.ApplicationModel;
using System;
using System.Reflection;
namespace Hua.Todo.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,137 @@
#if WINDOWS
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 Hua.Todo.Application;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Maui.Models;
using AppSettings = Hua.Todo.Maui.Models.AppSettings;
namespace Hua.Todo.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.CreateBuilder();
builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl);
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();
if (_appSettings.WebServer.IsUsingStatic)
{
ServeStaticFiles(app);
}
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseDynamicApi();
app.MapControllers();
_webApp = app;
await _webApp.StartAsync();
}
private void ServeStaticFiles(WebApplication app)
{
try
{
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. 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);
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;
}
}
#endif
@@ -0,0 +1,59 @@
using Hua.Todo.Maui.Services.Platforms;
namespace Hua.Todo.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 Hua.Todo.Maui.Models;
namespace Hua.Todo.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 Hua.Todo.Maui.Services;
public interface IEmbeddedWebServerService
{
bool IsRunning { get; }
string BaseUrl { get; }
Task StartAsync();
Task StopAsync();
}
@@ -0,0 +1,33 @@
namespace Hua.Todo.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 Hua.Todo.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,10 @@
namespace Hua.Todo.Maui.Services;
public sealed class NoopEmbeddedWebServerService : IEmbeddedWebServerService
{
public bool IsRunning => false;
public string BaseUrl => string.Empty;
public Task StartAsync() => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
}
@@ -0,0 +1,143 @@
#if MACCATALYST
using AppKit;
using Foundation;
namespace Hua.Todo.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 Hua.Todo.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 Hua.Todo.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 Hua.Todo.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 Hua.Todo.Maui.Services;
namespace Hua.Todo.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
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Hua.Todo.Maui.Views.MainPage">
<WebView x:Name="MainWebView" />
</ContentPage>
+120
View File
@@ -0,0 +1,120 @@
using Microsoft.Maui.Controls;
using Hua.Todo.Maui.Models;
using Hua.Todo.Maui.Services;
namespace Hua.Todo.Maui.Views
{
public partial class MainPage : ContentPage
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService? _webServer;
#if WINDOWS
private Platforms.Windows.WindowsKeyboardHandler? _keyboardHandler;
#endif
public MainPage(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebViewSource();
SetupWebViewCommunication();
SetupKeyboardHandler();
}
private void SetupWebViewSource()
{
if (_appSettings.WebServer.IsUsingStatic)
{
if (_webServer != null)
{
MainWebView.Source = _webServer.BaseUrl;
return;
}
}
MainWebView.Source = NormalizeUrl(_appSettings.WebServer.ForEndUrl);
}
private void SetupKeyboardHandler()
{
#if WINDOWS
_keyboardHandler = new Platforms.Windows.WindowsKeyboardHandler();
_keyboardHandler.EscKeyPressed += OnEscKeyPressed;
_keyboardHandler.Start();
#endif
}
private void OnEscKeyPressed(object? sender, EventArgs e)
{
var window = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault();
if (window != null)
{
#if WINDOWS
var windowService = new Platforms.Windows.WindowsWindowService();
windowService.MinimizeWindow(window);
#endif
}
}
private void SetupWebViewCommunication()
{
MainWebView.Navigated += async (s, e) =>
{
#if DEBUG
if (e.Result != WebNavigationResult.Success)
{
await DisplayAlertAsync("加载失败", $"{e.Url}\n{e.Result}", "OK");
}
#endif
if (_webServer is { IsRunning: true })
{
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
await MainWebView.EvaluateJavaScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.EvaluateJavaScriptAsync(@"
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);
}
});
");
};
}
private static string NormalizeUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return url;
var host = uri.Host;
if (host != "localhost" && host != "127.0.0.1") return url;
if (DeviceInfo.Platform == DevicePlatform.Android && DeviceInfo.DeviceType == DeviceType.Virtual)
{
var builder = new UriBuilder(uri) { Host = "10.0.2.2" };
return builder.Uri.ToString();
}
return url;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
},
"Development": {
},
"HotKey": {
"DefaultModifiers": "Alt",
"DefaultKey": "X",
"DefaultIsEnabled": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+55
View File
@@ -0,0 +1,55 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.1.4"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
; 压缩方式与级别一起写在 Compression 指令里(例如 lzma/fast、lzma/normal、zip/9 等)
; 注:当前环境下 lzma2 会被 ISCC 报 invalid,因此先用兼容性最好的 lzma/fast
Compression=zip/1
; 或 Compression=none ; 不压缩(仅打包,最快)
; 2. 强制 LZMA2 多线程(多核CPU
; LZMAUseSeparateProcess=yes
; LZMANumBlockThreads=1 ; 仅对 lzma2 生效
; 3. 降低压缩级别(默认 ultra,改 fast/normal
; (压缩级别已在 Compression 中指定)
; 注意: AppId 的值唯一标识此应用程序。不要在其他应用程序的安装程序中使用相同的 AppId 值。
; (若要生成新的 GUID,请在 IDE 中单击“工具”|“生成 GUID”。)
AppId={{9B9B7F4F-2345-6789-ABCD-EF1234567890}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
; 删除以下行以在管理安装模式下运行(为所有用户安装)。
PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=icon.ico
SolidCompression=no
WizardStyle=modern
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 注意: 请勿在任何共享系统文件上使用“Flags: ignoreversion”
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent