diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4059021 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +################################################################################ +# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。 +################################################################################ + +/Avalonia-PC/bin +/Avalonia-PC/.vs +/Avalonia-PC/obj \ No newline at end of file diff --git a/Avalonia-PC/App.axaml b/Avalonia-PC/App.axaml new file mode 100644 index 0000000..97a2bd1 --- /dev/null +++ b/Avalonia-PC/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Avalonia-PC/App.axaml.cs b/Avalonia-PC/App.axaml.cs new file mode 100644 index 0000000..cc86055 --- /dev/null +++ b/Avalonia-PC/App.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia_PC.ViewModels; +using Avalonia_PC.Views; + +namespace Avalonia_PC +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} \ No newline at end of file diff --git a/Avalonia-PC/Assets/avalonia-logo.ico b/Avalonia-PC/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Avalonia-PC/Assets/avalonia-logo.ico differ diff --git a/Avalonia-PC/Avalonia-PC.csproj b/Avalonia-PC/Avalonia-PC.csproj new file mode 100644 index 0000000..ed339ef --- /dev/null +++ b/Avalonia-PC/Avalonia-PC.csproj @@ -0,0 +1,30 @@ + + + WinExe + net10.0 + enable + app.manifest + true + + + + + + + PreserveNewest + + + + + + + + + + None + All + + + + + diff --git a/Avalonia-PC/Avalonia-PC.slnx b/Avalonia-PC/Avalonia-PC.slnx new file mode 100644 index 0000000..532a743 --- /dev/null +++ b/Avalonia-PC/Avalonia-PC.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Avalonia-PC/Program.cs b/Avalonia-PC/Program.cs new file mode 100644 index 0000000..0a1ed03 --- /dev/null +++ b/Avalonia-PC/Program.cs @@ -0,0 +1,35 @@ +using Avalonia; +using System; + +namespace Avalonia_PC +{ + internal sealed class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { +#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); + } + + + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); + } +} diff --git a/Avalonia-PC/ViewLocator.cs b/Avalonia-PC/ViewLocator.cs new file mode 100644 index 0000000..484d1fe --- /dev/null +++ b/Avalonia-PC/ViewLocator.cs @@ -0,0 +1,38 @@ +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")] + public class ViewLocator : IDataTemplate + { + 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 }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } + } +} diff --git a/Avalonia-PC/ViewModels/MainWindowViewModel.cs b/Avalonia-PC/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..812e4a5 --- /dev/null +++ b/Avalonia-PC/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,7 @@ +namespace Avalonia_PC.ViewModels +{ + public partial class MainWindowViewModel : ViewModelBase + { + public string Greeting { get; } = "Welcome to Avalonia!"; + } +} diff --git a/Avalonia-PC/ViewModels/ViewModelBase.cs b/Avalonia-PC/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..46985b7 --- /dev/null +++ b/Avalonia-PC/ViewModels/ViewModelBase.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Avalonia_PC.ViewModels +{ + public abstract class ViewModelBase : ObservableObject + { + } +} diff --git a/Avalonia-PC/Views/MainWindow.axaml b/Avalonia-PC/Views/MainWindow.axaml new file mode 100644 index 0000000..dee33b6 --- /dev/null +++ b/Avalonia-PC/Views/MainWindow.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/Avalonia-PC/Views/MainWindow.axaml.cs b/Avalonia-PC/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..2964059 --- /dev/null +++ b/Avalonia-PC/Views/MainWindow.axaml.cs @@ -0,0 +1,594 @@ +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 +{ + public partial class MainWindow : Window + { + private const string AppScheme = "app"; + private const string? OnlineStartupUrl = null; + private const string? LocalStartupPath = null; + private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private NativeWebView? _webView; + private bool _eventsAttached; + private object? _webViewAdapter; + private HttpListener? _localHttpServer; + private CancellationTokenSource? _localHttpServerCts; + private string? _localHttpBaseUrl; + private string? _localHttpRoot; + + public MainWindow() + { + InitializeComponent(); + + Opened += OnOpened; + Closed += OnClosed; + } + + 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(); + } + + 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(); + } + + private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e) + { + await InjectBridgeScriptAsync(); + } + + 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})"); + } + } + + 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)); + } + + private async Task InjectBridgeScriptAsync() + { + if (_webView is null) + { + return; + } + + const string script = """ +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; + + 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(); + } + + 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}`)); + }, 30000); + + 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: init?.method ?? request?.method ?? 'GET', + headers, + body: body ?? null + })); + + return responsePromise; + }; +} +"""; + + await _webView.InvokeScript(script); + } + + 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); + } + + 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("请求地址不能为空。")); + var path = uri.AbsolutePath.TrimStart('/'); + var normalizedPath = path.StartsWith("api/", StringComparison.OrdinalIgnoreCase) + ? path + : $"api/{path}"; + var authorization = GetAuthorizationHeader(headers); + _ = authorization; + + if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + response.StatusCode = 200; + response.StatusMessage = "OK"; + response.Body = JsonSerializer.Serialize(new { success = true }); + return response; + } + + if (string.Equals(normalizedPath, "api/getUser", StringComparison.OrdinalIgnoreCase)) + { + var user = await GetUserFromDatabaseAsync(); + response.Body = JsonSerializer.Serialize(new { success = true, data = user }); + return response; + } + + if (string.Equals(normalizedPath, "api/processData", StringComparison.OrdinalIgnoreCase)) + { + var input = string.Empty; + if (!string.IsNullOrWhiteSpace(body)) + { + using var jsonDocument = JsonDocument.Parse(body); + if (jsonDocument.RootElement.TryGetProperty("input", out var inputProperty)) + { + input = inputProperty.GetString() ?? string.Empty; + } + } + + var result = await ProcessDataAsync(input); + response.Body = JsonSerializer.Serialize(new { success = true, result }); + 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; + } + } + + 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; + } + + 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; + } + } + + 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; + } + + private static string? GetConfiguredLocalStartupPath() + { + if (!string.IsNullOrWhiteSpace(LocalStartupPath)) + { + return Path.GetFullPath(LocalStartupPath); + } + + return Path.Combine(AppContext.BaseDirectory, "www", "index.html"); + } + + 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 + { + } + } + } + + 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; + } + + 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; + } + } + + private static async Task GetUserFromDatabaseAsync() + { + await Task.Delay(100); + return new { id = 1, name = "张三", email = "zhangsan@example.com" }; + } + + private static async Task ProcessDataAsync(string? input) + { + await Task.Delay(200); + return $"Processed: {input?.ToUpperInvariant()}"; + } + + private sealed class AppResponse + { + public string Kind { get; set; } = string.Empty; + + public string? Id { get; set; } + + public int StatusCode { get; set; } + + public string StatusMessage { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public Dictionary Headers { get; set; } = new(); + } + } +} diff --git a/Avalonia-PC/app.manifest b/Avalonia-PC/app.manifest new file mode 100644 index 0000000..b02c8a5 --- /dev/null +++ b/Avalonia-PC/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/Avalonia-PC/www/api.js b/Avalonia-PC/www/api.js new file mode 100644 index 0000000..14b813b --- /dev/null +++ b/Avalonia-PC/www/api.js @@ -0,0 +1,50 @@ +// 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"), + processData: (input) => callApi("processData", { method: "POST", body: { input } }) +}; diff --git a/Avalonia-PC/www/index.html b/Avalonia-PC/www/index.html new file mode 100644 index 0000000..952f347 --- /dev/null +++ b/Avalonia-PC/www/index.html @@ -0,0 +1,41 @@ + + + + + 跨端测试 + + +

WebView2 自定义协议演示

+ + +

+
+    
+    
+
+