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:
@@ -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` 再用 `<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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"WebServer": {
|
||||
"Port": 5057,
|
||||
"IsUsingStatic": false,
|
||||
"IsUsingStatic": true,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
|
||||
Reference in New Issue
Block a user