diff --git a/docs/Android_NotFound_排查计划.md b/docs/Android_NotFound_排查计划.md new file mode 100644 index 0000000..d237cb8 --- /dev/null +++ b/docs/Android_NotFound_排查计划.md @@ -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` 映射为 AndroidAsset(Link 到 `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` 再用 ``/`` 统一打包(减少条件目标的不确定性) + +验证: +- 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 是最高优先级怀疑点) + diff --git a/src/TodoList.Application/Interfaces/ITaskService.cs b/src/TodoList.Application/Interfaces/ITaskService.cs index 4dd5e07..185194e 100644 --- a/src/TodoList.Application/Interfaces/ITaskService.cs +++ b/src/TodoList.Application/Interfaces/ITaskService.cs @@ -1,4 +1,3 @@ -using TodoList.Application.DynamicApi; using TodoList.Application.Models; namespace TodoList.Application.Interfaces; diff --git a/src/TodoList.Application/TodoList.Application.csproj b/src/TodoList.Application/TodoList.Application.csproj index e5087bf..c6fed9e 100644 --- a/src/TodoList.Application/TodoList.Application.csproj +++ b/src/TodoList.Application/TodoList.Application.csproj @@ -1,16 +1,20 @@ - net10.0 + net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst enable enable Library - + + + + + diff --git a/src/TodoList.Maui/App.xaml.cs b/src/TodoList.Maui/App.xaml.cs index 3c57495..85f08a1 100644 --- a/src/TodoList.Maui/App.xaml.cs +++ b/src/TodoList.Maui/App.xaml.cs @@ -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(); @@ -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()) { 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 }); diff --git a/src/TodoList.Maui/MauiProgram.cs b/src/TodoList.Maui/MauiProgram.cs index c642c17..9c70f39 100644 --- a/src/TodoList.Maui/MauiProgram.cs +++ b/src/TodoList.Maui/MauiProgram.cs @@ -36,6 +36,7 @@ public static class MauiProgram var connectionString = appSettings.WebServer.ConnectionString; builder.Services.AddApplicationServices(connectionString); + builder.Services.AddTransient(); builder.Services.AddSingleton(sp => new HotKeySettingsService(sp.GetRequiredService())); @@ -48,63 +49,101 @@ public static class MauiProgram return new NullSystemTrayService(); #endif }); +#if WINDOWS builder.Services.AddSingleton(); +#elif ANDROID + builder.Services.AddSingleton(); +#else + builder.Services.AddSingleton(); +#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(); - - // 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(); - context.Database.EnsureCreated(); - } - } - - var webServer = app.Services.GetRequiredService(); - _ = 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(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(json) ?? new AppSettings(); + var json = File.ReadAllText(settingsPath); + return JsonSerializer.Deserialize(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(); + + 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(); + context.Database.EnsureCreated(); + } + catch + { + } + } + } + + private static void StartWebServer(IServiceProvider services) + { + try + { + var webServer = services.GetRequiredService(); + _ = webServer.StartAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}"); + } } } diff --git a/src/TodoList.Maui/Platforms/Android/AndroidAssetFileProvider.cs b/src/TodoList.Maui/Platforms/Android/AndroidAssetFileProvider.cs new file mode 100644 index 0000000..1895ff1 --- /dev/null +++ b/src/TodoList.Maui/Platforms/Android/AndroidAssetFileProvider.cs @@ -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(); + 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 GetEnumerator() + { + foreach (var entry in _entries) + { + if (string.IsNullOrEmpty(entry)) continue; + + var childPath = $"{_dirPath}/{entry}"; + var childList = Array.Empty(); + var isDir = false; + + try + { + childList = _assets.List(childPath) ?? Array.Empty(); + isDir = childList.Length > 0; + } + catch + { + } + + yield return new AndroidAssetFileInfo(_assets, childPath, entry, isDir); + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml b/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml index 2617300..1e92b88 100644 --- a/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml +++ b/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,6 @@ - + - \ No newline at end of file + diff --git a/src/TodoList.Maui/Platforms/Android/MainActivity.cs b/src/TodoList.Maui/Platforms/Android/MainActivity.cs index abd2cdf..3973942 100644 --- a/src/TodoList.Maui/Platforms/Android/MainActivity.cs +++ b/src/TodoList.Maui/Platforms/Android/MainActivity.cs @@ -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 + } } diff --git a/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs b/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs new file mode 100644 index 0000000..6f410ae --- /dev/null +++ b/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs @@ -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(); + + 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(request.Body); + result = await taskService.CreateTaskAsync(dto); + } + else if (request.Method == "PUT" && tail.Length == 0) + { + methodName = nameof(ITaskService.UpdateTaskAsync); + var dto = DeserializeBody(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? errors = null; + if (exception != null) + { + errors = new List { 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(string body) where T : new() + { + if (string.IsNullOrWhiteSpace(body)) return new T(); + return JsonSerializer.Deserialize(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 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(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); +} diff --git a/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon.xml b/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon.xml new file mode 100644 index 0000000..c8faf89 --- /dev/null +++ b/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon_round.xml b/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon_round.xml new file mode 100644 index 0000000..c8faf89 --- /dev/null +++ b/src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon_round.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/src/TodoList.Maui/Services/EmbeddedWebServerService.cs b/src/TodoList.Maui/Services/EmbeddedWebServerService.cs index 8d37b92..95c1060 100644 --- a/src/TodoList.Maui/Services/EmbeddedWebServerService.cs +++ b/src/TodoList.Maui/Services/EmbeddedWebServerService.cs @@ -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; } -} \ No newline at end of file +} +#endif diff --git a/src/TodoList.Maui/Services/NoopEmbeddedWebServerService.cs b/src/TodoList.Maui/Services/NoopEmbeddedWebServerService.cs new file mode 100644 index 0000000..9db66bf --- /dev/null +++ b/src/TodoList.Maui/Services/NoopEmbeddedWebServerService.cs @@ -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; +} diff --git a/src/TodoList.Maui/TodoList.Maui.csproj b/src/TodoList.Maui/TodoList.Maui.csproj index 8cafc84..133c8e2 100644 --- a/src/TodoList.Maui/TodoList.Maui.csproj +++ b/src/TodoList.Maui/TodoList.Maui.csproj @@ -21,7 +21,6 @@ C:\Users\ShaoHua\AppData\Local\Android\Sdk - $(AndroidSdkDirectory)\ndk\25.2.9519653 待办事项 @@ -52,6 +51,7 @@ $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../TodoList.Web')) $(TodoListWebDir)\dist false + false @@ -62,6 +62,16 @@ False + false + true + apk + False + True + True + Assemblies + False + x86_64 + false @@ -93,11 +103,11 @@ + - @@ -113,16 +123,16 @@ - - + + @@ -135,6 +145,54 @@ + + + + + + + + + + <_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" /> + + + + + + + + + + wwwroot/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + <_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" /> + + + + + + + diff --git a/src/TodoList.Maui/Views/MainPage.xaml.cs b/src/TodoList.Maui/Views/MainPage.xaml.cs index edec49a..9f048cf 100644 --- a/src/TodoList.Maui/Views/MainPage.xaml.cs +++ b/src/TodoList.Maui/Views/MainPage.xaml.cs @@ -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() ?? 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(); - 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; + } } } diff --git a/src/TodoList.Maui/appsettings.json b/src/TodoList.Maui/appsettings.json index 842ac5f..bac8595 100644 --- a/src/TodoList.Maui/appsettings.json +++ b/src/TodoList.Maui/appsettings.json @@ -1,7 +1,7 @@ { "WebServer": { "Port": 5057, - "IsUsingStatic": false, + "IsUsingStatic": true, "ConnectionString": "", "HostUrl": "http://localhost:5057", "ForEndUrl": "http://localhost:5174"