1.更换软件协议为AGPL
2.切换项目名称为Hua.Todo
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
|
After Width: | Height: | Size: 90 KiB |
|
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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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
|
||||