From 40a91e39b6a53fb44d008b8b68657153236a70b0 Mon Sep 17 00:00:00 2001
From: ShaoHua <345265198@qqcom>
Date: Mon, 6 Apr 2026 21:07:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20Android=20?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E5=B9=B6=E5=AE=9E=E7=8E=B0=E5=89=8D=E7=AB=AF?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E6=9E=84=E5=BB=BA=E9=9B=86=E6=88=90?=
=?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20Android=20=E4=B8=93=E7=94=A8?=
=?UTF-8?q?=E7=9A=84=E5=B5=8C=E5=85=A5=E5=BC=8F=20Web=20=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=E5=99=A8=EF=BC=8C=E9=80=9A=E8=BF=87=20TcpListener=20=E6=8F=90?=
=?UTF-8?q?=E4=BE=9B=E9=9D=99=E6=80=81=E8=B5=84=E6=BA=90=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=20-=20=E5=9C=A8=20.csproj=20=E4=B8=AD=E9=9B=86=E6=88=90=20MSBu?=
=?UTF-8?q?ild=20=E4=BB=BB=E5=8A=A1=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E6=9E=84=E5=BB=BA=20TodoList.Web=20=E5=B9=B6=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5=E8=87=B3=20wwwroot=20-=20=E9=87=8D=E6=9E=84=20MainPag?=
=?UTF-8?q?e=20=E4=BB=A5=E6=94=AF=E6=8C=81=E4=BE=9D=E8=B5=96=E6=B3=A8?=
=?UTF-8?q?=E5=85=A5=EF=BC=8C=E5=B9=B6=E5=A4=84=E7=90=86=20Android=20?=
=?UTF-8?q?=E6=A8=A1=E6=8B=9F=E5=99=A8=20localhost=20(10.0.2.2)=20?=
=?UTF-8?q?=E6=98=A0=E5=B0=84=20-=20=E4=BC=98=E5=8C=96=20Android=20?=
=?UTF-8?q?=E8=B0=83=E8=AF=95=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=8C=85=E6=8B=AC?=
=?UTF-8?q?=E5=BF=AB=E9=80=9F=E9=83=A8=E7=BD=B2=E3=80=81ABI=20=E9=99=90?=
=?UTF-8?q?=E5=88=B6=E5=8F=8A=E7=A6=81=E7=94=A8=E8=B5=84=E6=BA=90=E7=BC=A9?=
=?UTF-8?q?=E6=94=BE=20-=20=E6=B7=BB=E5=8A=A0=20Android=20=E7=9F=A2?=
=?UTF-8?q?=E9=87=8F=E5=9B=BE=E6=A0=87=E8=B5=84=E6=BA=90=EF=BC=8C=E5=B9=B6?=
=?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E4=BB=A5?=
=?UTF-8?q?=E5=90=AF=E7=94=A8=E9=9D=99=E6=80=81=E6=96=87=E4=BB=B6=E6=A8=A1?=
=?UTF-8?q?=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/Android_NotFound_排查计划.md | 92 ++++
.../Interfaces/ITaskService.cs | 1 -
.../TodoList.Application.csproj | 8 +-
src/TodoList.Maui/App.xaml.cs | 10 +-
src/TodoList.Maui/MauiProgram.cs | 123 +++--
.../Android/AndroidAssetFileProvider.cs | 135 +++++
.../Platforms/Android/AndroidManifest.xml | 4 +-
.../Platforms/Android/MainActivity.cs | 10 +-
.../Android/MobileEmbeddedWebServerService.cs | 464 ++++++++++++++++++
.../Android/Resources/drawable/appicon.xml | 13 +
.../Resources/drawable/appicon_round.xml | 13 +
.../Services/EmbeddedWebServerService.cs | 19 +-
.../Services/NoopEmbeddedWebServerService.cs | 10 +
src/TodoList.Maui/TodoList.Maui.csproj | 66 ++-
src/TodoList.Maui/Views/MainPage.xaml.cs | 52 +-
src/TodoList.Maui/appsettings.json | 2 +-
16 files changed, 940 insertions(+), 82 deletions(-)
create mode 100644 docs/Android_NotFound_排查计划.md
create mode 100644 src/TodoList.Maui/Platforms/Android/AndroidAssetFileProvider.cs
create mode 100644 src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs
create mode 100644 src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon.xml
create mode 100644 src/TodoList.Maui/Platforms/Android/Resources/drawable/appicon_round.xml
create mode 100644 src/TodoList.Maui/Services/NoopEmbeddedWebServerService.cs
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"