feat: 优化 Android 支持并实现前端自动化构建集成

- 新增 Android 专用的嵌入式 Web 服务器,通过 TcpListener 提供静态资源服务
- 在 .csproj 中集成 MSBuild 任务,支持自动构建 TodoList.Web 并同步至 wwwroot
- 重构 MainPage 以支持依赖注入,并处理 Android 模拟器 localhost (10.0.2.2) 映射
- 优化 Android 调试配置,包括快速部署、ABI 限制及禁用资源缩放
- 添加 Android 矢量图标资源,并更新默认配置以启用静态文件模式
This commit is contained in:
ShaoHua
2026-04-06 21:07:10 +08:00
parent 4daa0c4eba
commit 40a91e39b6
16 changed files with 940 additions and 82 deletions
+92
View File
@@ -0,0 +1,92 @@
# Android 端显示 “Not Found” 排查计划(TodoList.Maui
## 目标
- 找出 Android 模拟器里只显示 `Not Found` 的根因(是 Web 资源缺失、内嵌 Web Server 路由/解析问题,还是 WebView 加载了错误地址)
- 给出可验证的修复方案,并确保修复后能在 Android 上正常加载前端页面
## 背景(当前实现快速定位)
- Android 使用自建 TCP HTTP Server,静态资源从 APK 的 `Assets/wwwroot/*` 读取:[MobileEmbeddedWebServerService](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs)
- WebView 默认加载内嵌服务器地址(`IsUsingStatic=true` 时):[MainPage.xaml.cs](file:///d:/Proj/TodoList/src/TodoList.Maui/Views/MainPage.xaml.cs)
- Android 端静态文件找不到时返回纯文本 `Not Found`[HandleStaticAsync](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs#L214-L255)
## 排查顺序(从“最可能 & 最省时间”到“深入原因”)
### 1) 确认 WebView 实际加载的 URL
- 在 Android Debug 输出里确认 WebView Source(期望是 `http://localhost:5057``http://localhost:5057/`
- 如果不是内嵌地址,检查 `appsettings.json``WebServer.IsUsingStatic``ForEndUrl` 配置:[appsettings.json](file:///d:/Proj/TodoList/src/TodoList.Maui/appsettings.json)
判定:
- 若加载的是内嵌地址 → 继续第 2 步
- 若加载的是外部地址(ForEndUrl)→ 重点查 ForEndUrl 对应服务是否启动/路由是否正确
### 2) 确认前端 dist 是否存在且可用于打包
- 检查 `src/TodoList.Web/dist/index.html` 是否存在
- 如果不存在:在 `src/TodoList.Web` 下执行 `npm ci` + `npm run build`,确保产物生成
判定:
- dist 不存在/为空 → “Not Found”高概率来自 Android 静态资源根本没被构建或没被打进 APK
### 3) 确认 Android APK 内是否真的包含 `Assets/wwwroot/index.html`
- 重点验证打包结果是否存在:
- `assets/wwwroot/index.html`
- `assets/wwwroot/assets/*`(至少有 js/css
- 项目里通过 MSBuild 目标把 `TodoList.Web/dist` 映射为 AndroidAssetLink 到 `wwwroot/...`):[TodoList.Maui.csproj](file:///d:/Proj/TodoList/src/TodoList.Maui/TodoList.Maui.csproj#L150-L175)
判定:
- APK 内没有 `wwwroot/index.html` → 修复构建/打包流程(第 6 步会给方案)
- APK 内有 `wwwroot/index.html` → 继续第 4 步
### 4) 记录 Android 内嵌服务器的“收到的请求 Path”与“找不到的 assetPath”
目的:判断是否是“请求行解析不兼容”或“路径格式异常”导致找不到资源。
-`ReadRequestAsync``HandleStaticAsync` 临时输出:
- requestLine / target / path
- 计算出的 assetPath
- TryOpenAsset 失败的 assetPath
高频根因候选:
- WebView 请求行使用 absolute-form(例如 `GET http://localhost:5057/ HTTP/1.1`),当前解析逻辑会把整个 URL 当作 path,最终拼成无效 `wwwroothttp://...`,导致 404
### 5) 排除 WebView/网络限制类问题(只在必要时做)
- 如果看到的不是纯文本 `Not Found`,而是加载错误/空白:
- 检查 Android 明文 HTTP`http://localhost`)是否被允许
- 检查 `network_security_config.xml` 与 Manifest 配置:[network_security_config.xml](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/Resources/xml/network_security_config.xml)、[AndroidManifest.xml](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml)
### 6) 修复与验证(根据前面判定选择)
#### A. 资源缺失/未打包
- 让构建流程更“硬性”:
- 若 dist 不存在则强制构建,或在 Debug 也保证 `AndroidAsset` 包含 dist
- 可选:把 dist 复制进 `TodoList.Maui/wwwroot` 再用 `<Content Include="wwwroot\**" />`/`<MauiAsset />` 统一打包(减少条件目标的不确定性)
验证:
- APK 内能看到 `assets/wwwroot/index.html`,启动后不再返回 `Not Found`
#### B. 请求路径解析不兼容(absolute-form 等)
- 改进 `ReadRequestAsync`:当 target 是 `http(s)://...` 时解析出其中的 Path + Query,再走现有逻辑
验证:
- 记录到的 path 变为 `/``/index.html`,能成功打开 `wwwroot/index.html`
#### C. 资源引用路径问题(js/css 请求 404)
- 检查 dist 中 `index.html``assets/*` 的引用路径是否与 AndroidAsset 的 Link 一致
- 若 Vite 输出含子目录(例如 `assets/chunks/...`),需要在 csproj 里用 `dist\**\*` 并保留 `%(RecursiveDir)`,避免扁平化导致引用断裂
验证:
- WebView 网络请求里 js/css 全部 200,页面正常渲染
## 本次排查的“最短闭环”
- 先确认 dist 是否存在 + APK 是否包含 `assets/wwwroot/index.html`
- 若存在仍 Not Found,再用日志确认 requestLine/target/path 是否被解析成异常值(absolute-form 是最高优先级怀疑点)
@@ -1,4 +1,3 @@
using TodoList.Application.DynamicApi;
using TodoList.Application.Models;
namespace TodoList.Application.Interfaces;
@@ -1,16 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
+6 -4
View File
@@ -15,6 +15,7 @@ namespace TodoList.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;
@@ -24,6 +25,7 @@ public partial class App : global::Microsoft.Maui.Controls.Application
public App(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
InitializeComponent();
_settingsService = serviceProvider.GetRequiredService<IHotKeySettingsService>();
@@ -33,7 +35,7 @@ public partial class App : global::Microsoft.Maui.Controls.Application
protected override Microsoft.Maui.Controls.Window CreateWindow(IActivationState? activationState)
{
_mainWindow = new Microsoft.Maui.Controls.Window(new MainPage())
_mainWindow = new Microsoft.Maui.Controls.Window(_serviceProvider.GetRequiredService<MainPage>())
{
Width = 450,
Height = 640,
@@ -121,10 +123,10 @@ protected override Microsoft.Maui.Controls.Window CreateWindow(IActivationState?
platformWindow?.Activate();
}
#else
if (global::Application.Current != null &&
!global::Application.Current.Windows.Contains(_mainWindow))
if (global::Microsoft.Maui.Controls.Application.Current != null &&
!global::Microsoft.Maui.Controls.Application.Current.Windows.Contains(_mainWindow))
{
global::Application.Current.OpenWindow(_mainWindow);
global::Microsoft.Maui.Controls.Application.Current.OpenWindow(_mainWindow);
}
#endif
});
+81 -42
View File
@@ -36,6 +36,7 @@ public static class MauiProgram
var connectionString = appSettings.WebServer.ConnectionString;
builder.Services.AddApplicationServices(connectionString);
builder.Services.AddTransient<Views.MainPage>();
builder.Services.AddSingleton<IHotKeySettingsService>(sp =>
new HotKeySettingsService(sp.GetRequiredService<AppSettings>()));
@@ -48,63 +49,101 @@ public static class MauiProgram
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();
// Ensure database directory exists and apply migrations
using (var scope = app.Services.CreateScope())
{
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
// Ensure database directory exists for the actual connection string
var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString);
var actualDbPath = sqliteBuilder.DataSource;
if (!string.IsNullOrEmpty(actualDbPath))
{
// If it's a relative path, we might need to resolve it,
// but for SQLite, it's usually better to have absolute paths.
// For MAUI, FileSystem.AppDataDirectory returns an absolute path.
var dbDir = Path.GetDirectoryName(actualDbPath);
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
}
// Ensure database is up to date
dbContext.Database.Migrate();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}");
// Fallback to EnsureCreated if Migrate fails (though Migrate is preferred)
using var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
context.Database.EnsureCreated();
}
}
var webServer = app.Services.GetRequiredService<IEmbeddedWebServerService>();
_ = webServer.StartAsync();
_ = Task.Run(() => InitializeDatabase(app.Services, connectionString));
_ = Task.Run(() => StartWebServer(app.Services));
return app;
}
private static AppSettings LoadAppSettings()
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(settingsPath))
try
{
return new AppSettings();
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();
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);
}
}
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 void StartWebServer(IServiceProvider services)
{
try
{
var webServer = services.GetRequiredService<IEmbeddedWebServerService>();
_ = webServer.StartAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}");
}
}
}
@@ -0,0 +1,135 @@
using Android.Content.Res;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
namespace TodoList.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();
}
}
@@ -1,6 +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="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
<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>
</manifest>
@@ -1,4 +1,4 @@
using Android.App;
using Android.App;
using Android.Content.PM;
using Android.OS;
@@ -7,4 +7,12 @@ namespace TodoList.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,464 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using TodoList.Application.Interfaces;
using TodoList.Application.Models;
using TodoList.Maui.Models;
namespace TodoList.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>
@@ -1,3 +1,4 @@
#if WINDOWS
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
@@ -63,7 +64,6 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
}
app.UseCors("AllowAll");
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseDynamicApi();
app.MapControllers();
@@ -75,16 +75,14 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
private void ServeStaticFiles(WebApplication app)
{
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled.");
return;
}
try
{
var 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);
@@ -135,4 +133,5 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
await _webApp.DisposeAsync();
_webApp = null;
}
}
}
#endif
@@ -0,0 +1,10 @@
namespace TodoList.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;
}
+62 -4
View File
@@ -21,7 +21,6 @@
<!-- Android SDK Path -->
<AndroidSdkDirectory>C:\Users\ShaoHua\AppData\Local\Android\Sdk</AndroidSdkDirectory>
<AndroidNdkDirectory>$(AndroidSdkDirectory)\ndk\25.2.9519653</AndroidNdkDirectory>
<!-- Display name -->
<ApplicationTitle>待办事项</ApplicationTitle>
@@ -52,6 +51,7 @@
<TodoListWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../TodoList.Web'))</TodoListWebDir>
<TodoListWebDistDir>$(TodoListWebDir)\dist</TodoListWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
@@ -62,6 +62,16 @@
<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'">
@@ -93,11 +103,11 @@
<!-- 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.AspNetCore.OpenApi" Version="10.0.1" />
<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" />
@@ -113,16 +123,16 @@
</ItemGroup>
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" LinkBase="wwwroot" />
<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" />
@@ -135,6 +145,54 @@
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<Target Name="BuildTodoListWeb"
BeforeTargets="UpdateAndroidAssets;BeforeBuild"
Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoListWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoListWebDistDir)\index.html'))">
<Exec Command="npm ci" WorkingDirectory="$(TodoListWebDir)"
Condition="Exists('$(TodoListWebDir)\package-lock.json') And !Exists('$(TodoListWebDir)\node_modules')" />
<Exec Command="npm install" WorkingDirectory="$(TodoListWebDir)"
Condition="!Exists('$(TodoListWebDir)\package-lock.json') And !Exists('$(TodoListWebDir)\node_modules')" />
<Exec Command="npm run build" WorkingDirectory="$(TodoListWebDir)" />
</Target>
<Target Name="SyncTodoListWebDistToMauiWwwroot"
BeforeTargets="ProcessMauiAssets"
DependsOnTargets="BuildTodoListWeb"
Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoListWebDistDir)\index.html')">
<ItemGroup>
<_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(MSBuildProjectDirectory)\wwwroot" Condition="Exists('$(MSBuildProjectDirectory)\wwwroot')" />
<MakeDir Directories="$(MSBuildProjectDirectory)\wwwroot" />
<Copy SourceFiles="@(_TodoListWebDistFiles)"
DestinationFiles="@(_TodoListWebDistFiles->'$(MSBuildProjectDirectory)\wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<ItemGroup>
<MauiAsset Include="@(_TodoListWebDistFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
<Target Name="CopyTodoListWebDistToWindowsWwwroot"
BeforeTargets="Build"
DependsOnTargets="BuildTodoListWeb"
Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoListWebDistDir)')">
<ItemGroup>
<_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<Copy SourceFiles="@(_TodoListWebDistFiles)"
DestinationFiles="@(_TodoListWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>
+37 -15
View File
@@ -7,15 +7,16 @@ namespace TodoList.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()
public MainPage(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = Microsoft.Maui.Controls.Application.Current?.Handler?.MauiContext?.Services
.GetService<AppSettings>() ?? new AppSettings();
_appSettings = appSettings;
_webServer = webServer;
SetupWebViewSource();
SetupWebViewCommunication();
@@ -25,21 +26,16 @@ namespace TodoList.Maui.Views
private void SetupWebViewSource()
{
if (_appSettings.WebServer.IsUsingStatic)
{
var webServer = Microsoft.Maui.Controls.Application.Current?.Handler?.MauiContext?.Services
.GetService<IEmbeddedWebServerService>();
if (webServer is { IsRunning: true })
if (_webServer != null)
{
MainWebView.Source = webServer.BaseUrl;
MainWebView.Source = _webServer.BaseUrl;
return;
}
}
else
{
MainWebView.Source = _appSettings.WebServer.ForEndUrl;
}
MainWebView.Source = NormalizeUrl(_appSettings.WebServer.ForEndUrl);
}
private void SetupKeyboardHandler()
@@ -67,10 +63,19 @@ namespace TodoList.Maui.Views
{
MainWebView.Navigated += async (s, e) =>
{
#if !DEBUG
await MainWebView.EvaluateJavaScriptAsync($"window.__API_BASE_URL__ = '{_appSettings.WebServer.HostUrl}/api';");
#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,
@@ -94,5 +99,22 @@ namespace TodoList.Maui.Views
");
};
}
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;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"