diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a0ea5b3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## 项目指南 +- 用户偏好:仅修改明确要求的内容,不要做额外改动(如未请求的 ViewModel DI 注册)。 \ No newline at end of file diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..97a2bd1 --- /dev/null +++ b/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..8c1845b --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,37 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia_PC.ViewModels; +using Avalonia_PC.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace Avalonia_PC +{ + /// + /// Avalonia 应用程序入口类,负责初始化 XAML 资源和设置主窗口。 + /// + public partial class App : Application + { + /// + /// 加载 Avalonia XAML 资源。 + /// + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + /// + /// 框架初始化完成后设置主窗口和数据上下文。 + /// + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = Program.Services.GetRequiredService(); + desktop.MainWindow.DataContext = new MainWindowViewModel(); + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/Assets/avalonia-logo.ico b/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Assets/avalonia-logo.ico differ diff --git a/Authentication/DefaultPcThirdPartyAuthorizationClient.cs b/Authentication/DefaultPcThirdPartyAuthorizationClient.cs new file mode 100644 index 0000000..0ff5ff4 --- /dev/null +++ b/Authentication/DefaultPcThirdPartyAuthorizationClient.cs @@ -0,0 +1,50 @@ +using Avalonia_Services.Services.AuthService; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + /// + /// 第三方授权客户端占位实现。接入真实第三方接口时替换此服务即可。 + /// + public sealed class DefaultPcThirdPartyAuthorizationClient : IPcThirdPartyAuthorizationClient + { + /// + /// 验证第三方授权码是否有效。默认实现将 "invalid" 视为授权丢失,其余视为有效。 + /// + /// 第三方授权码。 + /// 取消令牌。 + /// 授权检查结果。 + public Task ValidateAuthorizationCodeAsync( + string authorizationCode, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(authorizationCode) || + string.Equals(authorizationCode, "invalid", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost); + } + + return Task.FromResult(ThirdPartyAuthCheckResult.Valid); + } + + /// + /// 刷新第三方授权。默认实现总是返回 TemporaryFailure,表示暂时无法刷新。 + /// + /// 授权引用标识。 + /// 取消令牌。 + /// 授权检查结果。 + public Task RefreshAuthorizationAsync( + string authorizationReference, + CancellationToken cancellationToken = default) + { + if (string.Equals(authorizationReference, "invalid", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost); + } + + return Task.FromResult(ThirdPartyAuthCheckResult.TemporaryFailure); + } + } +} diff --git a/Authentication/PcAuthEndpointService.cs b/Authentication/PcAuthEndpointService.cs new file mode 100644 index 0000000..270b01b --- /dev/null +++ b/Authentication/PcAuthEndpointService.cs @@ -0,0 +1,94 @@ +using Authentication; +using Avalonia_Common.Core; +using Avalonia_Services.Core; +using Avalonia_Services.Services.AuthService; +using System; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + /// + /// PC 端鉴权端点服务,实现 , + /// 处理授权码登录、Token 刷新和登出操作。 + /// + public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// + public async Task AuthorizeAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode); + if (token is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "授权失败"); + } + + return ResponseHelper.Ok(token, "授权成功"); + } + + /// + public async Task RefreshAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + var refreshed = await tokenService.RefreshAsync(token); + if (refreshed is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "授权已失效"); + } + + return ResponseHelper.Ok(refreshed, "刷新成功"); + } + + /// + public Task LogoutAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + tokenService.Logout(token); + return Task.FromResult(ResponseHelper.Succeed("退出成功")); + } + + /// + /// 将 JSON 请求体反序列化为指定类型。 + /// + /// 目标类型。 + /// JSON 请求体字符串,可为空。 + /// 反序列化后的对象;若 body 为空则返回默认值。 + private static T? Deserialize(string? body) + { + return string.IsNullOrWhiteSpace(body) + ? default + : JsonSerializer.Deserialize(body, JsonOptions); + } + + /// + /// 从 Authorization 头中提取 Bearer Token。 + /// + /// Authorization 头的值。 + /// 提取的 Token 字符串;若无法提取则返回 null。 + private static string? ExtractBearerToken(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + /// + /// Bearer Token 的前缀常量。 + /// + const string prefix = "Bearer "; + return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? authorization[prefix.Length..].Trim() + : authorization.Trim(); + } + } +} diff --git a/Authentication/PcAuthService.cs b/Authentication/PcAuthService.cs new file mode 100644 index 0000000..0624539 --- /dev/null +++ b/Authentication/PcAuthService.cs @@ -0,0 +1,60 @@ +using Authentication; +using Avalonia_Services.Core; +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + /// + /// PC 端鉴权服务,基于全局 Token 验证用户身份,实现 。 + /// + public sealed class PcAuthService(PcGlobalTokenService tokenService) : IAuthService + { + /// + public async Task AuthenticateAsync(ServiceEndpointContext context) + { + var token = ExtractBearerToken(context.GetHeader("Authorization")); + if (!await tokenService.ValidateAsync(token)) + { + return null; + } + + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "pc-local"), + new Claim(ClaimTypes.Name, "PC授权用户"), + new Claim(ClaimTypes.Role, "SuperAdmin"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim("auth_type", "pc-global-token"), + ], + "pc-global-token"); + + return new ClaimsPrincipal(identity); + } + + /// + public Task AuthorizeAsync(ClaimsPrincipal user, string policy) + { + return Task.FromResult(user.Identity?.IsAuthenticated == true); + } + + /// + /// 从 Authorization 头中提取 Bearer Token。 + /// + /// Authorization 头的值。 + /// 提取的 Token 字符串;若无法提取则返回 null。 + private static string? ExtractBearerToken(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + string prefix = "Bearer "; + return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? authorization[prefix.Length..].Trim() + : authorization.Trim(); + } + } +} diff --git a/Authentication/PcGlobalTokenService.cs b/Authentication/PcGlobalTokenService.cs new file mode 100644 index 0000000..27e8bb8 --- /dev/null +++ b/Authentication/PcGlobalTokenService.cs @@ -0,0 +1,227 @@ +using Avalonia_Services.Services.AuthService; +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Authentication +{ + /// + /// PC 端全局 Token 服务,管理全局唯一的访问 Token。 + /// 支持授权码登录、Token 刷新、有效性验证和登出, + /// 在第三方授权暂时失败时使用缩短有效期的临时 Token。 + /// + public sealed class PcGlobalTokenService(IPcThirdPartyAuthorizationClient thirdPartyClient) + { + /// + /// 超级管理员角色集合。 + /// + private static readonly string[] SuperRoles = ["SuperAdmin", "Admin"]; + /// + /// 线程同步锁。 + /// + private readonly object _syncRoot = new(); + /// + /// 当前 Token 状态。 + /// + private PcTokenState? _current; + + /// + /// 正常 Token 有效期(8 小时)。 + /// + private static readonly TimeSpan NormalLifetime = TimeSpan.FromHours(8); + /// + /// 第三方暂时失败时的 Token 有效期(20 分钟)。 + /// + private static readonly TimeSpan TemporaryFailureLifetime = TimeSpan.FromMinutes(20); + /// + /// 第三方暂时失败的最长容忍窗口(24 小时),超出后清除 Token。 + /// + private static readonly TimeSpan MaxTemporaryFailureWindow = TimeSpan.FromHours(24); + + /// + /// 使用授权码进行登录授权,验证成功后颁发全局 Token。 + /// + /// 第三方授权码。 + /// 取消令牌。 + /// Token 响应;若授权码无效则返回 null。 + public async Task AuthorizeAsync(string? authorizationCode, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(authorizationCode)) + { + return null; + } + + var result = await thirdPartyClient.ValidateAuthorizationCodeAsync(authorizationCode, cancellationToken); + if (result != ThirdPartyAuthCheckResult.Valid) + { + return null; + } + + return IssueToken(authorizationCode, NormalLifetime, resetTemporaryFailureWindow: true); + } + + /// + /// 刷新当前 Token,向第三方验证授权引用是否仍然有效。 + /// 根据第三方返回结果决定是续期、降级为临时 Token 还是清除。 + /// + /// 当前 Token。 + /// 取消令牌。 + /// 新的 Token 响应;若授权丢失则返回 null。 + public async Task RefreshAsync(string? token, CancellationToken cancellationToken = default) + { + PcTokenState? current; + lock (_syncRoot) + { + current = IsCurrentToken(token) ? _current : null; + } + + if (current is null) + { + return null; + } + + var result = await thirdPartyClient.RefreshAuthorizationAsync(current.AuthorizationReference, cancellationToken); + return result switch + { + ThirdPartyAuthCheckResult.Valid => IssueToken(current.AuthorizationReference, NormalLifetime, resetTemporaryFailureWindow: true), + ThirdPartyAuthCheckResult.AuthorizationLost => ClearAndReturnNull(), + ThirdPartyAuthCheckResult.TemporaryFailure => RefreshAfterTemporaryFailure(current), + _ => null, + }; + } + + /// + /// 验证 Token 是否有效,若已过期则尝试自动刷新。 + /// + /// 要验证的 Token。 + /// 取消令牌。 + /// Token 是否有效。 + public async Task ValidateAsync(string? token, CancellationToken cancellationToken = default) + { + PcTokenState? current; + lock (_syncRoot) + { + if (!IsCurrentToken(token)) + { + return false; + } + + current = _current; + if (current is not null && current.ExpiresAt > DateTime.UtcNow) + { + return true; + } + } + + return await RefreshAsync(token, cancellationToken) is not null; + } + + /// + /// 登出并清除当前 Token。 + /// + /// 要清除的 Token。 + public void Logout(string? token) + { + lock (_syncRoot) + { + if (IsCurrentToken(token)) + { + _current = null; + } + } + } + + /// + /// 颁发新的全局 Token。 + /// + /// 授权引用标识。 + /// Token 有效期。 + /// 是否重置暂时失败窗口。 + /// 包含 Token 和过期时间的响应。 + private PcTokenResponse IssueToken(string authorizationReference, TimeSpan lifetime, bool resetTemporaryFailureWindow) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var now = DateTime.UtcNow; + var state = new PcTokenState( + HashToken(token), + authorizationReference, + now.Add(lifetime), + resetTemporaryFailureWindow ? null : _current?.TemporaryFailureStartedAt ?? now); + + lock (_syncRoot) + { + _current = state; + } + + return new PcTokenResponse(token, state.ExpiresAt, SuperRoles); + } + + /// + /// 在第三方暂时失败时刷新 Token。若超出最大容忍窗口则清除 Token。 + /// + /// 当前 Token 状态。 + /// 新的临时 Token 响应;若超出容忍窗口则返回 null。 + private PcTokenResponse? RefreshAfterTemporaryFailure(PcTokenState current) + { + var startedAt = current.TemporaryFailureStartedAt ?? DateTime.UtcNow; + if (DateTime.UtcNow - startedAt > MaxTemporaryFailureWindow) + { + return ClearAndReturnNull(); + } + + return IssueToken(current.AuthorizationReference, TemporaryFailureLifetime, resetTemporaryFailureWindow: false); + } + + /// + /// 清除当前 Token 并返回 null。 + /// + /// 始终返回 null。 + private PcTokenResponse? ClearAndReturnNull() + { + lock (_syncRoot) + { + _current = null; + } + + return null; + } + + /// + /// 检查给定 Token 是否与当前持有的 Token 匹配。 + /// + /// 要检查的 Token。 + /// 是否匹配。 + private bool IsCurrentToken(string? token) + { + return !string.IsNullOrWhiteSpace(token) && + _current is not null && + string.Equals(_current.TokenHash, HashToken(token), StringComparison.Ordinal); + } + + /// + /// 对 Token 原文进行 SHA256 哈希,返回十六进制字符串。 + /// + /// Token 原文。 + /// SHA256 哈希后的十六进制字符串。 + private static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes); + } + + /// + /// 保存当前全局 Token 的内部状态。 + /// + /// Token 的 SHA256 哈希值。 + /// 授权引用标识。 + /// 过期时间。 + /// 第三方暂时失败的起始时间。 + private sealed record PcTokenState( + string TokenHash, + string AuthorizationReference, + DateTime ExpiresAt, + DateTime? TemporaryFailureStartedAt); + } +} diff --git a/Avalonia-PC.csproj b/Avalonia-PC.csproj new file mode 100644 index 0000000..9be16cf --- /dev/null +++ b/Avalonia-PC.csproj @@ -0,0 +1,48 @@ + + + WinExe + net10.0 + enable + app.manifest + Assets\avalonia-logo.ico + true + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + None + All + + + + + + + + + + + + diff --git a/Avalonia-PC.slnx b/Avalonia-PC.slnx new file mode 100644 index 0000000..a181409 --- /dev/null +++ b/Avalonia-PC.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..2283dd5 --- /dev/null +++ b/Program.cs @@ -0,0 +1,97 @@ +using Authentication; +using Avalonia; +using Avalonia_Common.Infrastructure; +using Avalonia_EFCore.Database; +using Avalonia_PC.Authentication; +using Avalonia_PC.Views; +using Avalonia_Services.Core; +using Avalonia_Services.Endpoints; +using Avalonia_Services.Services; +using Avalonia_Services.Services.AuthService; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using System; + +namespace Avalonia_PC +{ + /// + /// 桌面应用程序入口类,负责配置 DI 容器、初始化数据库和启动 Avalonia 框架。 + /// + internal sealed class Program + { + /// + /// 获取全局 DI 服务提供程序。 + /// + public static IServiceProvider Services { get; private set; } = null!; + + /// + /// 应用程序主入口点。 + /// + /// 命令行参数。 + [STAThread] + public static void Main(string[] args) + { + // 初始化日志系统 + AppLog.Initialize(LoggingConfiguration.CreateDefaultLogger(logDir: "logs")); + + AppLog.Information("Avalonia-PC 正在启动..."); + + ConfigureServices(); + + // 初始化数据库(自动迁移 + 种子数据) + Services.InitializeDatabase(); + +#if DEBUG + // 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页 + Environment.SetEnvironmentVariable( + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "--remote-debugging-port=9222 --auto-open-devtools-for-tabs"); +#endif + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + /// + /// 配置 DI 容器,注册数据库、业务服务、鉴权服务和统一端点。 + /// + private static void ConfigureServices() + { + var services = new ServiceCollection(); + + // ---- 数据库 ---- + // 注册默认数据库提供程序(SQLite / MySQL / PostgreSQL / SqlServer) + DatabaseProviderRegistry.RegisterDefaults(); + + // 桌面端固定使用 SQLite 本地数据库 + services.AddAppDatabase(DatabaseConfiguration.ForSQLite("app.db")); + + // ---- 业务服务 ---- + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ---- 端点注册 ---- + var endpointBuilder = new ServiceEndpointBuilder(); + AppEndpoints.Configure(endpointBuilder); + AuthEndpoints.ConfigurePc(endpointBuilder); + var endpoints = endpointBuilder.Build(); + services.AddSingleton(endpoints); + + // 注册 Window + services.AddTransient(sp => new MainWindow(sp)); + + Services = services.BuildServiceProvider(); + } + + /// + /// 构建 Avalonia 应用程序(供可视化设计器使用,请勿删除)。 + /// + /// Avalonia 应用构建器。 + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..05300e7 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Avalonia-PC": { + "commandName": "Project" + }, + "WSL": { + "commandName": "WSL2", + "distributionName": "" + } + } +} \ No newline at end of file diff --git a/ViewLocator.cs b/ViewLocator.cs new file mode 100644 index 0000000..dfc998d --- /dev/null +++ b/ViewLocator.cs @@ -0,0 +1,53 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia_PC.ViewModels; +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Avalonia_PC +{ + /// + /// Given a view model, returns the corresponding view if possible. + /// + [RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] + /// + /// 视图定位器,根据 ViewModel 类型自动查找对应的 View, + /// 实现 IDataTemplate 以支持 Avalonia 的数据模板机制。 + /// + public class ViewLocator : IDataTemplate + { + /// + /// 根据 ViewModel 实例构建对应的 View 控件。 + /// 约定:将 ViewModels 命名空间中的 ViewModel 替换为 Views 命名空间中的同名 View。 + /// + /// ViewModel 实例。 + /// 对应的 View 控件;若未找到则返回 TextBlock 显示错误信息。 + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + /// + /// 判断数据对象是否为 ViewModel 类型(以 "ViewModel" 结尾)。 + /// + /// 要判断的数据对象。 + /// 是否为 ViewModel。 + public bool Match(object? data) + { + return data is ViewModelBase; + } + } +} diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..50bf8e5 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,13 @@ +namespace Avalonia_PC.ViewModels +{ + /// + /// 主窗口的 ViewModel,提供问候语等绑定属性。 + /// + public partial class MainWindowViewModel : ViewModelBase + { + /// + /// 获取问候语文本。 + /// + public string Greeting { get; } = "Welcome to Avalonia!"; + } +} diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..e1414cd --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Avalonia_PC.ViewModels +{ + /// + /// ViewModel 基类,继承自 CommunityToolkit.Mvvm 的 ObservableObject, + /// 提供属性变更通知功能。 + /// + public abstract class ViewModelBase : ObservableObject + { + } +} diff --git a/Views/MainWindow.BridgeScript.cs b/Views/MainWindow.BridgeScript.cs new file mode 100644 index 0000000..41210d3 --- /dev/null +++ b/Views/MainWindow.BridgeScript.cs @@ -0,0 +1,345 @@ +namespace Avalonia_PC.Views +{ + /// + /// MainWindow 的分部类,定义注入 WebView2 的 JavaScript Bridge 脚本。 + /// + public partial class MainWindow + { + private const string BridgeScript = """ +if (!window.__appBridgeInstalled) { + window.__appBridgeInstalled = true; + window.isWebView2 = true; + const pending = new Map(); + + const tryOpenDevTools = () => { + window.invokeCSharpAction(JSON.stringify({ kind: 'app-open-devtools' })); + }; + + window.__dispatchAppResponse = function(jsonStr) { + const payload = JSON.parse(jsonStr); + const responseId = payload.id ?? payload.Id; + const entry = pending.get(responseId); + if (!entry) return; + pending.delete(responseId); + entry.resolve(new Response(payload.body ?? payload.Body ?? '', { + status: payload.statusCode ?? payload.StatusCode ?? 200, + statusText: payload.statusMessage ?? payload.StatusMessage ?? 'OK', + headers: payload.headers ?? payload.Headers ?? { 'Content-Type': 'application/json' } + })); + }; + + const nativeFetch = window.fetch ? window.fetch.bind(window) : null; + const NativeXMLHttpRequest = window.XMLHttpRequest; + + const sendAppBridgeRequest = ({ requestUrl, method, headers, body, timeoutMs = 30000 }) => { + const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + + const responsePromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out waiting for ${requestUrl}`)); + }, timeoutMs); + + pending.set(id, { + resolve: response => { clearTimeout(timeoutId); resolve(response); }, + reject: error => { clearTimeout(timeoutId); reject(error); } + }); + }); + + window.invokeCSharpAction(JSON.stringify({ + kind: 'app-request', + id, + url: requestUrl, + method, + headers, + body + })); + + return responsePromise; + }; + + document.addEventListener('keydown', event => { + if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && (event.key === 'I' || event.key === 'i'))) { + event.preventDefault(); + tryOpenDevTools(); + } + }, true); + + document.addEventListener('contextmenu', event => { + if (event.shiftKey) { + event.preventDefault(); + tryOpenDevTools(); + } + }, true); + + window.fetch = async (input, init) => { + const request = input instanceof Request ? input : null; + const requestUrl = typeof input === 'string' || input instanceof URL + ? input.toString() + : request?.url; + + if (!requestUrl || !requestUrl.startsWith('app://')) { + if (!nativeFetch) throw new Error('window.fetch is not available.'); + return nativeFetch(input, init); + } + + const combinedHeaders = new Headers(request?.headers); + if (init?.headers) { + new Headers(init.headers).forEach((value, key) => combinedHeaders.set(key, value)); + } + + const headers = {}; + combinedHeaders.forEach((value, key) => headers[key] = value); + + let body = init?.body; + if (body === undefined && request) { + body = await request.clone().text(); + } + + if (body && typeof body !== 'string') { + body = await new Response(body).text(); + } + + return sendAppBridgeRequest({ + requestUrl, + method: init?.method ?? request?.method ?? 'GET', + headers, + body: body ?? null, + timeoutMs: 30000 + }); + }; + + /// + /// WebView2 Bridge 中的 XMLHttpRequest 替代实现,将 app:// 请求拦截并转为 C# 调用。 + /// + class BridgeXMLHttpRequest { + constructor() { + this._native = new NativeXMLHttpRequest(); + this._isAppRequest = false; + this._requestUrl = ''; + this._method = 'GET'; + this._headers = {}; + this._responseHeaders = {}; + this._responseHeadersRaw = ''; + this._aborted = false; + + this.readyState = 0; + this.status = 0; + this.statusText = ''; + this.response = null; + this.responseText = ''; + this.responseType = ''; + this.responseURL = ''; + this.timeout = 0; + this.withCredentials = false; + + this.onreadystatechange = null; + this.onload = null; + this.onerror = null; + this.ontimeout = null; + this.onabort = null; + this.onloadend = null; + + this.upload = { + addEventListener: () => {}, + removeEventListener: () => {} + }; + + this._native.onreadystatechange = () => { + if (this._isAppRequest) { + return; + } + + this.readyState = this._native.readyState; + this.status = this._native.status; + this.statusText = this._native.statusText; + this.responseURL = this._native.responseURL ?? ''; + this.response = this._native.response; + this.responseText = this._native.responseText ?? ''; + this._raiseReadyStateChange(); + }; + + this._native.onload = event => { + if (!this._isAppRequest && typeof this.onload === 'function') { + this.onload(event); + } + }; + + this._native.onerror = event => { + if (!this._isAppRequest && typeof this.onerror === 'function') { + this.onerror(event); + } + }; + + this._native.ontimeout = event => { + if (!this._isAppRequest && typeof this.ontimeout === 'function') { + this.ontimeout(event); + } + }; + + this._native.onabort = event => { + if (!this._isAppRequest && typeof this.onabort === 'function') { + this.onabort(event); + } + }; + + this._native.onloadend = event => { + if (!this._isAppRequest && typeof this.onloadend === 'function') { + this.onloadend(event); + } + }; + } + + open(method, url, async = true, user, password) { + const requestUrl = typeof url === 'string' || url instanceof URL + ? url.toString() + : `${url ?? ''}`; + + this._requestUrl = requestUrl; + this._method = method ?? 'GET'; + this._isAppRequest = requestUrl.startsWith('app://'); + this._headers = {}; + this._responseHeaders = {}; + this._responseHeadersRaw = ''; + this._aborted = false; + + if (!this._isAppRequest) { + this._native.open(method, url, async, user, password); + return; + } + + this.readyState = 1; + this._raiseReadyStateChange(); + } + + setRequestHeader(name, value) { + if (!this._isAppRequest) { + this._native.setRequestHeader(name, value); + return; + } + + this._headers[name] = value; + } + + getAllResponseHeaders() { + if (!this._isAppRequest) { + return this._native.getAllResponseHeaders(); + } + + return this._responseHeadersRaw; + } + + getResponseHeader(name) { + if (!this._isAppRequest) { + return this._native.getResponseHeader(name); + } + + return this._responseHeaders[name.toLowerCase()] ?? null; + } + + overrideMimeType(mimeType) { + if (!this._isAppRequest && typeof this._native.overrideMimeType === 'function') { + this._native.overrideMimeType(mimeType); + } + } + + abort() { + if (!this._isAppRequest) { + this._native.abort(); + return; + } + + this._aborted = true; + if (typeof this.onabort === 'function') { + this.onabort(); + } + if (typeof this.onloadend === 'function') { + this.onloadend(); + } + } + + async send(body = null) { + if (!this._isAppRequest) { + this._native.send(body); + return; + } + + let requestBody = body; + if (requestBody && typeof requestBody !== 'string') { + requestBody = await new Response(requestBody).text(); + } + + try { + const response = await sendAppBridgeRequest({ + requestUrl: this._requestUrl, + method: this._method, + headers: this._headers, + body: requestBody ?? null, + timeoutMs: this.timeout > 0 ? this.timeout : 30000 + }); + + if (this._aborted) { + return; + } + + this.status = response.status; + this.statusText = response.statusText; + this.responseURL = this._requestUrl; + + this._responseHeaders = {}; + this._responseHeadersRaw = ''; + response.headers.forEach((value, key) => { + this._responseHeaders[key.toLowerCase()] = value; + this._responseHeadersRaw += `${key}: ${value}\r\n`; + }); + + const text = await response.text(); + this.responseText = text; + this.response = this.responseType === 'json' + ? (text ? JSON.parse(text) : null) + : text; + + this.readyState = 4; + this._raiseReadyStateChange(); + + if (typeof this.onload === 'function') { + this.onload(); + } + if (typeof this.onloadend === 'function') { + this.onloadend(); + } + } catch (error) { + if (this._aborted) { + return; + } + + this.status = 0; + this.statusText = ''; + this.readyState = 4; + this._raiseReadyStateChange(); + + const errorMessage = error?.message ?? ''; + if (errorMessage.includes('Timed out waiting') && typeof this.ontimeout === 'function') { + this.ontimeout(error); + } else if (typeof this.onerror === 'function') { + this.onerror(error); + } + + if (typeof this.onloadend === 'function') { + this.onloadend(); + } + } + } + + _raiseReadyStateChange() { + if (typeof this.onreadystatechange === 'function') { + this.onreadystatechange(); + } + } + } + + window.XMLHttpRequest = BridgeXMLHttpRequest; +} +"""; + } +} diff --git a/Views/MainWindow.Routes.cs b/Views/MainWindow.Routes.cs new file mode 100644 index 0000000..856c3fd --- /dev/null +++ b/Views/MainWindow.Routes.cs @@ -0,0 +1,38 @@ +using Avalonia_Services.Core; +using Avalonia_Services.Endpoints; +using Avalonia_Services.Extensions; +using Avalonia_Services.Services; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Avalonia_PC.Views +{ + /// + /// MainWindow 的分部类,负责路由注册和统一端点适配。 + /// + public partial class MainWindow + { + /// + /// 统一端点适配器(替代原来的 _routes 字典)。 + /// 所有端点在 Avalonia-Services/AppEndpoints.cs 中统一定义。 + /// + private DesktopEndpointAdapter _endpointAdapter = null!; + + /// + /// 服务容器,通过构造函数注入。 + /// + private IServiceProvider _services = null!; + + /// + /// 从 DI 获取统一端点集合并构建桌面适配器。 + /// + private void RegisterRoutes() + { + // 从 DI 获取已构建的端点集合 + var endpointCollection = _services.GetRequiredService(); + _endpointAdapter = endpointCollection.CreateAdapter(_services); + } + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..dee33b6 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..88cd6dd --- /dev/null +++ b/Views/MainWindow.axaml.cs @@ -0,0 +1,684 @@ +using Avalonia.Controls; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia_PC.Views +{ + /// + /// 主窗口,承载 WebView2 控件并管理前后端 Bridge 通信。 + /// + public partial class MainWindow : Window + { + /// + /// 自定义协议方案名称。 + /// + private const string AppScheme = "app"; + /// + /// 在线模式下的前端启动 URL。 + /// + private const string? OnlineStartupUrl = "http://localhost:51240"; + //private const string? OnlineStartupUrl = null; + /// + /// 离线模式下的前端本地文件路径,为空则使用在线模式。 + /// + private const string? LocalStartupPath = null; + private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// WebView2 原生控件实例。 + /// + private NativeWebView? _webView; + /// + /// 标记 WebView 事件是否已绑定。 + /// + private bool _eventsAttached; + /// + /// WebView 适配器对象。 + /// + private object? _webViewAdapter; + /// + /// 本地 HTTP 服务器实例(离线模式)。 + /// + private HttpListener? _localHttpServer; + /// + /// 本地 HTTP 服务器的取消令牌源。 + /// + private CancellationTokenSource? _localHttpServerCts; + /// + /// 本地 HTTP 服务器的基础 URL。 + /// + private string? _localHttpBaseUrl; + /// + /// 本地 HTTP 服务器的根目录路径。 + /// + private string? _localHttpRoot; + + #region 生命周期与 WebView 事件 + + /// + /// 初始化窗口并注册生命周期事件。 + /// + public MainWindow(IServiceProvider services) + { + _services = services; + InitializeComponent(); + Opened += OnOpened; + Closed += OnClosed; + + RegisterRoutes(); + } + + /// + /// 窗口打开后初始化 WebView、挂载事件并加载入口页面。 + /// + private async void OnOpened(object? sender, EventArgs e) + { + if (_eventsAttached) + { + return; + } + + _webView = this.FindControl("WebView"); + if (_webView is null) + { + return; + } + + _eventsAttached = true; + _webView.NavigationCompleted += OnNavigationCompleted; + _webView.WebMessageReceived += OnWebMessageReceived; + _webView.AdapterCreated += OnAdapterCreated; + + await LoadInitialContentAsync(); + } + + /// + /// WebView 适配器创建后缓存实例,用于后续打开开发者工具。 + /// + private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e) + { + _webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e); + } + + /// + /// 窗口关闭时解绑事件并释放本地资源。 + /// + private void OnClosed(object? sender, EventArgs e) + { + if (_webView is not null) + { + _webView.NavigationCompleted -= OnNavigationCompleted; + _webView.WebMessageReceived -= OnWebMessageReceived; + _webView.AdapterCreated -= OnAdapterCreated; + } + + _webViewAdapter = null; + StopLocalHttpServer(); + } + + /// + /// 页面导航完成后注入 JS 桥接脚本。 + /// + private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e) + { + await InjectBridgeScriptAsync(); + } + + #endregion + + #region 前端桥接与页面加载 + + /// + /// 接收前端消息并进行分发(打开调试工具 / 处理 app 请求)。 + /// + private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e) + { + var messageJson = e.Body; + if (string.IsNullOrWhiteSpace(messageJson)) + { + return; + } + + AppResponse? response = null; + + try + { + using var document = JsonDocument.Parse(messageJson); + var root = document.RootElement; + + if (!root.TryGetProperty("kind", out var kindProperty)) + { + return; + } + + var kind = kindProperty.GetString(); + if (string.Equals(kind, "app-open-devtools", StringComparison.OrdinalIgnoreCase)) + { + TryOpenDevTools(); + return; + } + + if (!string.Equals(kind, "app-request", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + response = await HandleAppRequestAsync(root); + } + catch (Exception ex) + { + response = new AppResponse + { + Kind = "app-response", + Id = TryGetRequestId(messageJson), + StatusCode = 500, + StatusMessage = "Internal Server Error", + Body = JsonSerializer.Serialize(new { success = false, error = ex.Message }), + Headers = CreateJsonHeaders(), + }; + } + + if (_webView is not null && response is not null) + { + var responseJson = JsonSerializer.Serialize(response, BridgeJsonSerializerOptions); + var responseJsonLiteral = JsonSerializer.Serialize(responseJson); + await _webView.InvokeScript($"window.__dispatchAppResponse({responseJsonLiteral})"); + } + } + + /// + /// 加载初始页面:优先在线地址,其次本地路径(通过本地 HTTP 服务托管)。 + /// + private async Task LoadInitialContentAsync() + { + if (_webView is null) + { + return; + } + + var onlineUrl = GetConfiguredOnlineStartupUrl(); + if (onlineUrl is not null) + { + StopLocalHttpServer(); + _webView.Source = onlineUrl; + return; + } + + var localHtmlPath = GetConfiguredLocalStartupPath(); + if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath)) + { + return; + } + + var localRoot = Path.GetDirectoryName(localHtmlPath); + if (string.IsNullOrWhiteSpace(localRoot)) + { + return; + } + + await EnsureLocalHttpServerStartedAsync(localRoot); + if (string.IsNullOrWhiteSpace(_localHttpBaseUrl)) + { + _webView.Source = new Uri(localHtmlPath); + return; + } + + _webView.Source = new Uri(new Uri(_localHttpBaseUrl), Path.GetFileName(localHtmlPath)); + } + + /// + /// 向页面注入桥接脚本,接管 app:// 请求并回传到 C# 处理。 + /// + private async Task InjectBridgeScriptAsync() + { + if (_webView is null) + { + return; + } + + await _webView.InvokeScript(BridgeScript); + } + + #endregion + + #region 请求分发与通用响应 + + /// + /// 解析前端请求消息并转发到统一请求处理入口。 + /// + private async Task HandleAppRequestAsync(JsonElement request) + { + var id = request.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null; + var url = request.TryGetProperty("url", out var urlProperty) ? urlProperty.GetString() : null; + var method = request.TryGetProperty("method", out var methodProperty) ? methodProperty.GetString() : "GET"; + var body = request.TryGetProperty("body", out var bodyProperty) ? bodyProperty.GetString() : null; + var headers = ExtractHeaders(request); + + return await HandleAppRequestAsync(id, url, method, body, headers); + } + + /// + /// 统一请求处理:构建上下文、处理 OPTIONS、使用统一端点适配器分发。 + /// + private async Task HandleAppRequestAsync( + string? id, + string? rawUrl, + string? method, + string? body, + Dictionary headers) + { + var response = new AppResponse + { + Kind = "app-response", + Id = id, + StatusCode = 200, + StatusMessage = "OK", + Headers = CreateJsonHeaders(), + }; + + try + { + var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。")); + + if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + response.StatusCode = 200; + response.StatusMessage = "OK"; + response.Body = JsonSerializer.Serialize(new { success = true }); + return response; + } + + // 使用统一端点适配器处理请求 + var (normalizedPath, queryParams) = ParseRequestUri(uri); + + var routeResult = await _endpointAdapter.HandleRequestAsync( + path: normalizedPath, + method: method ?? "GET", + body: body, + headers: headers, + query: queryParams); + + if (routeResult.IsMatched) + { + response.StatusCode = routeResult.StatusCode; + response.StatusMessage = routeResult.StatusMessage; + response.Body = BuildSuccessResponseBody(routeResult.Data); + foreach (var kvp in routeResult.ResponseHeaders) + { + response.Headers[kvp.Key] = kvp.Value; + } + return response; + } + + response.StatusCode = 404; + response.StatusMessage = "Not Found"; + response.Body = JsonSerializer.Serialize(new { success = false, error = "API not found" }); + return response; + } + catch (Exception ex) + { + response.StatusCode = 500; + response.StatusMessage = "Internal Server Error"; + response.Body = JsonSerializer.Serialize(new { success = false, error = ex.Message }); + return response; + } + } + + /// + /// 从 URI 解析规范化路径和查询参数(供统一端点适配器使用)。 + /// + private static (string normalizedPath, Dictionary query) ParseRequestUri(Uri uri) + { + var host = uri.Host ?? string.Empty; + var absolutePath = uri.AbsolutePath ?? string.Empty; + var combinedPath = $"{host}/{absolutePath}"; + + var pathSegments = combinedPath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Uri.UnescapeDataString) + .ToArray(); + + var normalizedPath = string.Join('/', pathSegments); + var query = ParseQueryParameters(uri.Query); + + return (normalizedPath, query); + } + + /// + /// 统一构建成功响应体,保持前后端响应结构一致。 + /// + private static string BuildSuccessResponseBody(object? data) + { + return JsonSerializer.Serialize(new { success = true, data }); + } + + /// + /// 解析查询字符串为忽略大小写的字典。 + /// + private static Dictionary ParseQueryParameters(string? queryString) + { + var query = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(queryString)) + { + return query; + } + + var raw = queryString.TrimStart('?'); + foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separatorIndex = pair.IndexOf('='); + if (separatorIndex < 0) + { + query[Uri.UnescapeDataString(pair)] = string.Empty; + continue; + } + + var key = Uri.UnescapeDataString(pair[..separatorIndex]); + var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]); + query[key] = value; + } + + return query; + } + + + /// + /// 创建桥接响应的默认 JSON/CORS 头。 + /// + private static Dictionary CreateJsonHeaders() => new() + { + ["Content-Type"] = "application/json; charset=utf-8", + ["Access-Control-Allow-Origin"] = "*", + ["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS", + ["Access-Control-Allow-Headers"] = "Content-Type, Authorization", + }; + + /// + /// 从前端请求消息中提取请求头。 + /// + private static Dictionary ExtractHeaders(JsonElement request) + { + if (!request.TryGetProperty("headers", out var headersElement) || + headersElement.ValueKind != JsonValueKind.Object) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in headersElement.EnumerateObject()) + { + headers[property.Name] = property.Value.GetString() ?? string.Empty; + } + + return headers; + } + + /// + /// 获取授权头,供鉴权逻辑扩展使用。 + /// + private static string? GetAuthorizationHeader(Dictionary headers) + { + return headers.FirstOrDefault( + entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value; + } + + /// + /// 在异常情况下尝试提取请求 id,确保前端可收到对应错误响应。 + /// + private static string? TryGetRequestId(string messageJson) + { + try + { + using var document = JsonDocument.Parse(messageJson); + return document.RootElement.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null; + } + catch + { + return null; + } + } + + #endregion + + #region 页面地址配置与本地静态服务 + + /// + /// 获取在线启动地址配置(仅允许 http/https)。 + /// + private static Uri? GetConfiguredOnlineStartupUrl() + { + if (string.IsNullOrWhiteSpace(OnlineStartupUrl)) + { + return null; + } + + if (!Uri.TryCreate(OnlineStartupUrl, UriKind.Absolute, out var uri)) + { + return null; + } + + return uri.Scheme is "http" or "https" ? uri : null; + } + + /// + /// 获取本地启动文件路径,未配置时默认使用输出目录 www/index.html。 + /// + private static string? GetConfiguredLocalStartupPath() + { + if (!string.IsNullOrWhiteSpace(LocalStartupPath)) + { + return Path.GetFullPath(LocalStartupPath); + } + + return Path.Combine(AppContext.BaseDirectory, "www", "index.html"); + } + + /// + /// 确保本地 HTTP 静态服务已启动;根目录变化时会重启。 + /// + private async Task EnsureLocalHttpServerStartedAsync(string localRoot) + { + if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl) && + _localHttpServer is not null && + string.Equals(_localHttpRoot, localRoot, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + StopLocalHttpServer(); + + var port = GetAvailableTcpPort(); + var prefix = $"http://127.0.0.1:{port}/"; + + _localHttpServerCts = new CancellationTokenSource(); + _localHttpServer = new HttpListener(); + _localHttpServer.Prefixes.Add(prefix); + _localHttpServer.Start(); + _localHttpBaseUrl = prefix; + _localHttpRoot = localRoot; + + _ = Task.Run(() => RunLocalHttpServerLoopAsync(_localHttpServer, _localHttpServerCts.Token, localRoot)); + } + + /// + /// 本地静态服务主循环,持续接收并分发请求。 + /// + private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var context = await listener.GetContextAsync(); + _ = Task.Run(() => HandleLocalHttpRequest(context, wwwRoot), cancellationToken); + } + } + catch + { + } + } + + /// + /// 处理本地静态资源请求并返回文件内容。 + /// + private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot) + { + try + { + var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty; + if (string.IsNullOrWhiteSpace(relativePath)) + { + relativePath = "index.html"; + } + + relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); + var fullPath = Path.GetFullPath(Path.Combine(wwwRoot, relativePath)); + var fullRoot = Path.GetFullPath(wwwRoot); + + if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase) || !File.Exists(fullPath)) + { + context.Response.StatusCode = 404; + context.Response.Close(); + return; + } + + context.Response.ContentType = GetContentType(fullPath); + await using var input = File.OpenRead(fullPath); + context.Response.ContentLength64 = input.Length; + await input.CopyToAsync(context.Response.OutputStream); + context.Response.OutputStream.Close(); + } + catch + { + try + { + context.Response.StatusCode = 500; + context.Response.Close(); + } + catch + { + } + } + } + + /// + /// 根据后缀返回静态资源 Content-Type。 + /// + private static string GetContentType(string filePath) + { + return Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".html" => "text/html; charset=utf-8", + ".js" => "application/javascript; charset=utf-8", + ".css" => "text/css; charset=utf-8", + ".json" => "application/json; charset=utf-8", + _ => "application/octet-stream", + }; + } + + /// + /// 获取一个可用本地端口,用于启动本地静态服务。 + /// + private static int GetAvailableTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// 尝试打开 WebView 开发者工具(兼容不同适配器方法名)。 + /// + private void TryOpenDevTools() + { + if (_webViewAdapter is null) + { + return; + } + + var adapterType = _webViewAdapter.GetType(); + var method = adapterType.GetMethod("OpenDevTools", BindingFlags.Public | BindingFlags.Instance) ?? + adapterType.GetMethod("ShowDevTools", BindingFlags.Public | BindingFlags.Instance); + method?.Invoke(_webViewAdapter, null); + } + + /// + /// 停止并释放本地静态服务资源。 + /// + private void StopLocalHttpServer() + { + try + { + _localHttpServerCts?.Cancel(); + _localHttpServer?.Stop(); + _localHttpServer?.Close(); + } + catch + { + } + finally + { + _localHttpServerCts?.Dispose(); + _localHttpServerCts = null; + _localHttpServer = null; + _localHttpBaseUrl = null; + _localHttpRoot = null; + } + } + + #endregion + + #region DTO / 路由上下文模型 + + /// + /// Bridge 通信响应 DTO,用于序列化返回给前端的数据。 + /// + private sealed class AppResponse + { + /// + /// 获取或设置响应类型标识。 + /// + public string Kind { get; set; } = string.Empty; + + /// + /// 获取或设置请求 ID(对应前端请求)。 + /// + public string? Id { get; set; } + + /// + /// 获取或设置 HTTP 状态码。 + /// + public int StatusCode { get; set; } + + /// + /// 获取或设置状态描述文本。 + /// + public string StatusMessage { get; set; } = string.Empty; + + /// + /// 获取或设置响应体 JSON 字符串。 + /// + public string Body { get; set; } = string.Empty; + + /// + /// 获取或设置响应头字典。 + /// + public Dictionary Headers { get; set; } = new(); + } + + #endregion + } +} diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..b02c8a5 --- /dev/null +++ b/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/www/api.js b/www/api.js new file mode 100644 index 0000000..f14fc86 --- /dev/null +++ b/www/api.js @@ -0,0 +1,51 @@ +// api.js - 跨端统一 API 调用层 + +const isWebView2 = () => { + return window.isWebView2 === true; +}; + +const getBaseUrl = () => { + if (isWebView2()) { + return "app://api/"; + } + + return "https://your-production-api.com/api/"; +}; + +async function callApi(endpoint, options = {}) { + const url = getBaseUrl() + endpoint; + const fetchOptions = { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + ...(options.headers || {}) + }, + ...(options.body && { body: JSON.stringify(options.body) }) + }; + + const token = localStorage.getItem("authToken"); + if (token) { + fetchOptions.headers.Authorization = `Bearer ${token}`; + } + + try { + const response = await fetch(url, fetchOptions); + const data = await response.json(); + console.log(data) + + if (!response.ok) { + throw new Error(data.error || `HTTP ${response.status}`); + } + + return data; + } catch (err) { + console.error(`API call failed: ${endpoint}`, err); + throw err; + } +} + +window.api = { + getUser: () => callApi("getUser?t=1"), + processData: (input) => callApi("processData", { method: "POST", body: { input } }), + wData: (input) => callApi("wData", { method: "POST", body: { input } }), +}; diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..fe5428f --- /dev/null +++ b/www/index.html @@ -0,0 +1,62 @@ + + + + + 跨端测试 + + +

WebView2 自定义协议演示

+ + + +

+
+    
+    
+
+