Files
WebToWindowsApp-CSharp/MainWindow.xaml.cs
2025-12-14 16:17:15 +08:00

564 lines
25 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using Microsoft.Win32;
namespace WebToApp
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private AppConfig _config = new();
private StaticFileServer? _server;
private int? _serverPort;
private WebView2? Web => this.FindName("WebView") as WebView2;
public MainWindow()
{
InitializeComponent();
// 提前加载配置并应用窗口设置,确保在窗口显示前生效(避免先显示尺寸再全屏的卡顿)
try { LoadConfig(); ApplyWindowSettings(); } catch { }
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
}
//主窗口加载
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
try
{
// 初始化 WebView2
await InitializeWebView2Async();
// 计算并导航 URL
var url = await GetTargetUrlAsync();
if (string.IsNullOrWhiteSpace(url))
{
MessageBox.Show("无法获取有效的URL程序退出", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
Close();
return;
}
Web!.Source = new Uri(url);
}
catch (Exception ex)
{
MessageBox.Show($"启动失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
Close();
}
}
//主窗口关闭
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
try { _server?.Stop(); } catch { /* ignore */ }
}
//加载配置
private void LoadConfig()
{
try
{
// 优先使用开发环境相对路径: ./config/config.json
var baseDir = AppContext.BaseDirectory;
var devConfigPath = Path.Combine(baseDir, "config", "config.json");
if (File.Exists(devConfigPath))
{
var json = File.ReadAllText(devConfigPath, Encoding.UTF8);
_config = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new AppConfig();
return;
}
// 默认配置(在线网页)
_config = new AppConfig
{
= "网页应用",
= "在线网页",
线 = new AppConfig.线 { = "https://www.example.com" },
= new AppConfig.()
};
}
catch
{
_config = new AppConfig();
}
}
//将配置应用到窗口设置
private void ApplyWindowSettings()
{
Title = string.IsNullOrWhiteSpace(_config.) ? "网页应用" : _config.;
if (_config. is not null)
{
// 先处理全屏:如果全屏为 true则直接最大化并忽略宽高设置
if (_config..)
{
WindowState = WindowState.Maximized;
}
else
{
Width = _config.. > 0 ? _config.. + 16 : 1216;
Height = _config.. > 0 ? _config.. + 39 : 839;
WindowState = WindowState.Normal;
}
Topmost = _config..;
ResizeMode = _config.. ? ResizeMode.CanResize : ResizeMode.NoResize;
if (!string.IsNullOrWhiteSpace(_config.logo))
{
var iconPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, _config.logo));
if (File.Exists(iconPath))
{
try
{
var uri = new Uri(iconPath);
Icon = System.Windows.Media.Imaging.BitmapFrame.Create(uri);
}
catch { /* ignore bad icon */ }
}
}
}
}
//初始化异步加载webview
private async Task InitializeWebView2Async()
{
await Web!.EnsureCoreWebView2Async();
var settings = Web!.CoreWebView2.Settings;
settings.IsScriptEnabled = true;
settings.AreDefaultContextMenusEnabled = false; // 禁用默认右键菜单,使用自定义
settings.AreDevToolsEnabled = false;
// 设置移动端 UA与示例一致
Web!.CoreWebView2.Settings.UserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1";
// 处理 JS 消息
Web!.CoreWebView2.WebMessageReceived += CoreWebView2_WebMessageReceived;
// 处理下载(普通 a 链接下载)
Web!.CoreWebView2.DownloadStarting += CoreWebView2_DownloadStarting;
// 文档创建即注入脚本(滚动条隐藏、右键菜单、下载拦截)
await Web!.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(BuildInjectedScript());
// 再次在 DOMContentLoaded 时执行一次,确保注入在有些页面上生效
Web!.CoreWebView2.DOMContentLoaded += async (_, __) =>
{
try { await Web!.CoreWebView2.ExecuteScriptAsync(BuildInjectedScript()); } catch { }
};
}
private async Task<string?> GetTargetUrlAsync()
{
if (_config. == "本地网页" && _config. is not null)
{
var webDir = _config.. ?? "config/web";
var entry = _config.. ?? "index.html";
var display = _config.. ?? "http服务器";
var baseDir = AppContext.BaseDirectory;
var absWebDir = Path.GetFullPath(Path.Combine(baseDir, webDir));
if (display == "直接本地")
{
var entryPath = Path.GetFullPath(Path.Combine(absWebDir, entry));
if (File.Exists(entryPath))
{
var uri = new Uri(entryPath);
return uri.AbsoluteUri; // file:///... 路径
}
else
{
MessageBox.Show($"网页文件不存在: {entryPath}");
return null;
}
}
else
{
// 启动内置 HTTP 服务器
_server = new StaticFileServer(absWebDir);
_serverPort = _server.StartOnAvailablePort();
var url = $"http://0.0.0.0:{_serverPort}/{entry}";
return url;
}
}
else if (_config. == "在线网页" && _config.线 is not null)
{
return _config.线.;
}
return null;
}
private void CoreWebView2_DownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e)
{
try
{
// 打开保存对话框
var sfd = new SaveFileDialog
{
FileName = Path.GetFileName(e.ResultFilePath),
Filter = "所有文件 (*.*)|*.*"
};
if (sfd.ShowDialog() == true)
{
e.ResultFilePath = sfd.FileName;
e.Handled = true; // 使用我们自己的保存,不显示默认 UI
}
else
{
e.Cancel = true;
}
}
catch
{
// 失败则走默认
}
}
private async void CoreWebView2_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var json = e.WebMessageAsJson;
var msg = JsonSerializer.Deserialize<WebMsg>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (msg == null) return;
switch (msg.Type)
{
case "download":
await HandleInterceptedDownloadAsync(msg);
break;
case "show_about":
ShowAboutPage();
break;
case "open_in_browser":
if (!string.IsNullOrWhiteSpace(msg.Url))
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = msg.Url, UseShellExecute = true }); }
catch { }
}
break;
}
}
catch { /* ignore */ }
}
private async Task HandleInterceptedDownloadAsync(WebMsg msg)
{
try
{
var fileName = string.IsNullOrWhiteSpace(msg.Filename) ? $"下载文件_{DateTime.Now:yyyyMMdd_HHmmss}" : SanitizeFileName(msg.Filename!);
var filter = "所有文件 (*.*)|*.*";
if (!string.IsNullOrWhiteSpace(msg.ContentType) && msg.ContentType.StartsWith("text/"))
{
filter = "文本文件 (*.txt)|*.txt|所有文件 (*.*)|*.*";
}
var sfd = new SaveFileDialog
{
FileName = fileName,
Filter = filter
};
if (sfd.ShowDialog() == true)
{
byte[] bytes;
if (msg.IsBase64)
{
bytes = Convert.FromBase64String(msg.Content ?? string.Empty);
}
else
{
bytes = Encoding.UTF8.GetBytes(msg.Content ?? string.Empty);
}
await File.WriteAllBytesAsync(sfd.FileName, bytes);
}
}
catch
{
// 忽略错误
}
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
foreach (var ch in invalid)
{
name = name.Replace(ch, '_');
}
return name.Trim(' ', '.');
}
private void ShowAboutPage()
{
try
{
var baseDir = AppContext.BaseDirectory;
var aboutPath = Path.Combine(baseDir, "config/aboutpage", "about.html");
if (File.Exists(aboutPath))
{
Web!.CoreWebView2.Navigate(new Uri(aboutPath).AbsoluteUri);
}
else
{
// 备用:简单 About 内容
var html = "data:text/html;charset=utf-8," + Uri.EscapeDataString("<html><head><meta charset='utf-8'><title>关于</title></head><body><h1>关于</h1><p>这是示例的关于页面。</p></body></html>");
Web!.CoreWebView2.Navigate(html);
}
}
catch { /* ignore */ }
}
private string BuildInjectedScript()
{
var hideScroll = _config.?. == true;
var menuEnabled = _config.?. == true;
var interceptEnabled = _config.?. == true;
var css = hideScroll ? @"/* 隐藏所有滚动条 */
::-webkit-scrollbar { width:0px; height:0px; background:transparent; }
html { scrollbar-width: none; }
body { -ms-overflow-style: none; }
* { scrollbar-width: none; -ms-overflow-style: none; }
*::-webkit-scrollbar { width:0px; height:0px; background:transparent; }" : string.Empty;
var sb = new StringBuilder();
sb.AppendLine("(() => {");
sb.AppendLine(" function setup(){");
sb.AppendLine(" try {");
if (!string.IsNullOrEmpty(css))
{
sb.AppendLine(" try { var style=document.createElement('style'); style.textContent=`" + css.Replace("`", "\\`") + "`; (document.head||document.documentElement).appendChild(style); } catch(e){ console.warn('样式注入失败', e);} ");
}
if (menuEnabled)
{
sb.AppendLine(@" try {
const oldMenu = document.getElementById('custom-context-menu'); if (oldMenu) oldMenu.remove();
const m = document.createElement('div');
m.id = 'custom-context-menu';
m.style.cssText = 'position:fixed;background:#fff;border:1px solid #ccc;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);padding:8px 0;z-index:10000;display:none;min-width:120px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px';
function item(t,fn){ const b=document.createElement('button'); b.textContent=t; b.style.cssText='padding:8px 16px;cursor:pointer;border:none;background:none;width:100%;text-align:left;font-size:14px;color:#333'; b.onmouseenter=()=>b.style.background='#f0f0f0'; b.onmouseleave=()=>b.style.background='transparent'; b.onclick=()=>{ fn(); hide(); }; return b; }
function hide(){ m.style.display='none'; }
function show(x,y){ m.style.left=x+'px'; m.style.top=y+'px'; m.style.display='block'; const r=m.getBoundingClientRect(); if(r.right>innerWidth) m.style.left=(x-r.width)+'px'; if(r.bottom>innerHeight) m.style.top=(y-r.height)+'px'; }
m.appendChild(item('← 返回', ()=>history.back()));
m.appendChild(item('🔄 刷新', ()=>location.reload()));
m.appendChild(item(' 关于', ()=>{ try{ if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'show_about'}); } }catch(_){ } }));
(document.body||document.documentElement).appendChild(m);
document.addEventListener('click', hide, {passive:true});
document.addEventListener('scroll', hide, {passive:true});
addEventListener('resize', hide, {passive:true});
document.addEventListener('contextmenu', function(e){ e.preventDefault(); show(e.clientX,e.clientY); }, false);
} catch(e) { console.warn('右键菜单设置失败', e); }
");
}
if (interceptEnabled)
{
sb.AppendLine(@" try {
function isDownloadLink(a){ if(!a||!a.href) return false; if(a.hasAttribute('download')) return true; const u=(a.href||'').toLowerCase(); const exts=['.txt','.pdf','.doc','.docx','.xls','.xlsx','.ppt','.pptx','.zip','.rar','.7z','.tar','.gz','.jpg','.jpeg','.png','.gif','.bmp','.svg','.webp','.mp3','.wav','.ogg','.mp4','.avi','.mov','.wmv','.json','.xml','.csv','.md','.log']; for(const e of exts){ if(u.includes(e)) return true; } if(u.startsWith('data:')||u.startsWith('blob:')) return true; const kws=['download','export','save','下载','导出','保存']; const t=(a.textContent||'').toLowerCase(); const ti=(a.title||'').toLowerCase(); return kws.some(k=>t.includes(k)||ti.includes(k)); }
async function toBase64FromBlob(b){ const ab=await b.arrayBuffer(); const bytes=new Uint8Array(ab); let bin=''; const size=0x8000; for(let i=0;i<bytes.length;i+=size){ bin+=String.fromCharCode.apply(null, bytes.subarray(i,i+size)); } return btoa(bin); }
async function handle(link){ const url=link.href; const fn=link.download||url.split('/').pop()||'download'; try { if(url.startsWith('data:')) { const [h,d]=url.split(','); const is64 = h.includes('base64'); const m=/data:([^;]+)/.exec(h); const ct=m?m[1]:'application/octet-stream'; const content=is64?d:decodeURIComponent(d); if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'download', filename:fn, contentType:ct, isBase64:is64, content:content}); } return; } if(url.startsWith('blob:')){ const r=await fetch(url); const b=await r.blob(); const ct=b.type||'application/octet-stream'; const content=await toBase64FromBlob(b); if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'download', filename:fn, contentType:ct, isBase64:true, content}); } return; } if(url.startsWith('/')||url.startsWith('./')||url.startsWith('../')||url.startsWith(location.origin)){ const r=await fetch(url); const ct=r.headers.get('content-type')||'application/octet-stream'; if(ct.startsWith('text/')||ct.includes('json')){ const txt=await r.text(); if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'download', filename:fn, contentType:ct, isBase64:false, content:txt}); } } else { const b=await r.blob(); const content=await toBase64FromBlob(b); if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'download', filename:fn, contentType:ct, isBase64:true, content}); } } return; } if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'open_in_browser', url}); } } catch(e) { if(window.chrome&&window.chrome.webview){ window.chrome.webview.postMessage({type:'open_in_browser', url}); } } }
function intercept(){ document.addEventListener('click', function(ev){ const a=ev.target && ev.target.closest ? ev.target.closest('a') : null; if(a && isDownloadLink(a)){ ev.preventDefault(); ev.stopPropagation(); handle(a); } }, true); }
intercept();
} catch(e) { console.warn('下载拦截设置失败', e); }
");
}
sb.AppendLine(" } catch(e) { console.warn('setup执行失败', e); }");
sb.AppendLine(" }");
sb.AppendLine(" if (document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', setup); } else { setup(); }");
sb.AppendLine("})();");
return sb.ToString();
}
}
internal class WebMsg
{
public string? Type { get; set; }
public string? Filename { get; set; }
public string? ContentType { get; set; }
public bool IsBase64 { get; set; }
public string? Content { get; set; }
public string? Url { get; set; }
}
internal class AppConfig
{
public string { get; set; } = "网页应用";
public string { get; set; } = "在线网页"; // 本地网页 / 在线网页
public string logo { get; set; } = string.Empty; // 可选:窗口图标
// 旧字段兼容(建议改用 注入设置.隐藏网页滚动条)
public bool { get; set; } = false;
public ? { get; set; } = new();
public ? { get; set; } = new();
public ? { get; set; }
public 线? 线 { get; set; }
public class
{
public bool { get; set; } = false;
public bool { get; set; } = true;
public bool { get; set; } = true;
}
public class
{
public int { get; set; } = 1200;
public int { get; set; } = 800;
public bool { get; set; } = true;
public bool { get; set; } = false;
public bool { get; set; } = true;
public bool { get; set; } = false;
}
public class
{
public string? { get; set; }
public string? { get; set; }
public string? { get; set; } // 直接本地 / http服务器
}
public class 线
{
public string { get; set; } = "https://www.example.com";
}
}
internal class StaticFileServer
{
private readonly string _root;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
public StaticFileServer(string rootDirectory)
{
_root = rootDirectory;
}
public int StartOnAvailablePort(int startPort = 8080)
{
var port = FindAvailablePort(startPort);
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:" + port + "/");
_listener.Start();
_ = Task.Run(() => AcceptLoopAsync(_cts.Token));
return port;
}
public void Stop()
{
try { _cts?.Cancel(); } catch { }
try { _listener?.Stop(); } catch { }
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
if (_listener == null) return;
while (!ct.IsCancellationRequested)
{
HttpListenerContext? ctx = null;
try { ctx = await _listener.GetContextAsync(); }
catch { if (ct.IsCancellationRequested) break; }
if (ctx == null) continue;
_ = Task.Run(() => HandleRequestAsync(ctx));
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
try
{
var req = ctx.Request;
var relPath = req.Url?.AbsolutePath ?? "/";
relPath = WebUtility.UrlDecode(relPath).TrimStart('/');
if (string.IsNullOrEmpty(relPath)) relPath = "index.html";
// 防止越权访问
var fullPath = Path.GetFullPath(Path.Combine(_root, relPath.Replace('/', Path.DirectorySeparatorChar)));
if (!fullPath.StartsWith(Path.GetFullPath(_root), StringComparison.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 403;
ctx.Response.Close();
return;
}
if (!File.Exists(fullPath))
{
ctx.Response.StatusCode = 404;
ctx.Response.Close();
return;
}
var bytes = await File.ReadAllBytesAsync(fullPath);
ctx.Response.ContentType = GetContentType(fullPath);
ctx.Response.ContentLength64 = bytes.LongLength;
await ctx.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length);
ctx.Response.OutputStream.Close();
}
catch
{
try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { }
}
}
private static string GetContentType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".html" or ".htm" => "text/html; charset=utf-8",
".js" => "application/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".ico" => "image/x-icon",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream"
};
}
private static int FindAvailablePort(int start)
{
for (var p = start; p < 65535; p++)
{
try
{
var l = new HttpListener();
l.Prefixes.Add($"http://127.0.0.1:" + p + "/");
l.Start();
l.Stop();
return p;
}
catch { /* try next */ }
}
throw new InvalidOperationException("无法找到可用的端口");
}
}
}