初始化提交

This commit is contained in:
2025-12-14 16:17:15 +08:00
commit 93cdc9b7b5
22 changed files with 1277 additions and 0 deletions

166
.gitignore vendored Normal file
View File

@@ -0,0 +1,166 @@
## Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
## Visual Studio cache/options directory
.vs/
.vscode/
## Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
## Rider
.idea/
*.sln.iml
## User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
## Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
## .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
## NuGet Packages
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
*.nuget.props
*.nuget.targets
## MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
## NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
## Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
## .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
## Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
## Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
## Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
## ReSharper
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
## JetBrains Rider
.idea/
*.sln.iml
## WebView2 runtime cache
*.exe.WebView2/
EBWebView/
## Temporary files
*.tmp
*.temp
*.swp
*~
.DS_Store
Thumbs.db
## User-specific project files
*.csproj.user
*.user
## Build output
publish/
*.rar
*.zip
## Config files (if contains sensitive data, uncomment)
# config/config.json

9
App.xaml Normal file
View File

@@ -0,0 +1,9 @@
<Application x:Class="WebToApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WebToApp"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

14
App.xaml.cs Normal file
View File

@@ -0,0 +1,14 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebToApp
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
}

10
AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

13
MainWindow.xaml Normal file
View File

@@ -0,0 +1,13 @@
<Window x:Class="WebToApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WebToApp"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
mc:Ignorable="d"
Title="WebToApp" Height="800" Width="1200">
<Grid>
<wv2:WebView2 x:Name="WebView" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
</Grid>
</Window>

564
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,564 @@
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("无法找到可用的端口");
}
}
}

183
README.md Normal file
View File

@@ -0,0 +1,183 @@
# WebToApp
一个基于 WPF 和 WebView2 的网页转桌面应用工具,可以将在线网页或本地网页打包成 Windows 桌面应用程序。
## 功能特性
- 🌐 **支持在线网页**:直接加载远程网页地址
- 📁 **支持本地网页**:支持本地 HTML 文件,可通过 HTTP 服务器或直接文件方式加载
- 🎨 **自定义窗口设置**:可配置窗口大小、置顶、全屏等属性
- 🖱️ **自定义右键菜单**:提供返回、刷新、关于等快捷操作
- 📥 **下载拦截**:智能拦截下载链接,使用系统保存对话框
- 🎯 **滚动条隐藏**:可隐藏网页滚动条,提供更沉浸的体验
- 🖼️ **自定义图标和标题**:支持自定义应用图标和窗口标题
## 技术栈
- **.NET 8.0** - 跨平台开发框架
- **WPF** - Windows Presentation Foundation
- **WebView2** - 基于 Chromium 的 WebView 控件
## 系统要求
- Windows 10/11
- .NET 8.0 Runtime如果使用独立部署则不需要
- WebView2 Runtime通常已预装或会自动下载
## 快速开始
### 1. 克隆项目
```bash
git clone https://github.com/yourusername/WebToApp.git
cd WebToApp
```
### 2. 配置应用
编辑 `config/config.json` 文件来配置你的应用:
#### 在线网页配置示例
```json
{
"软件名称": "我的应用",
"软件logo": "config/logo.png",
"网页类型": "在线网页",
"在线网页": {
"链接地址": "https://example.com"
},
"注入设置": {
"隐藏网页滚动条": true,
"自定义右键菜单": true,
"拦截下载链接": true
},
"窗口设置": {
"窗口宽度": 1200,
"窗口高度": 800,
"窗口可调整大小": true,
"窗口置顶": false,
"窗口阴影": true,
"全屏": false
}
}
```
#### 本地网页配置示例
```json
{
"软件名称": "我的应用",
"软件logo": "config/logo.png",
"网页类型": "本地网页",
"本地网页": {
"网页目录": "config/web",
"网页入口": "index.html",
"展示模式": "http服务器"
},
"注入设置": {
"隐藏网页滚动条": true,
"自定义右键菜单": true,
"拦截下载链接": true
},
"窗口设置": {
"窗口宽度": 1200,
"窗口高度": 800,
"窗口可调整大小": true,
"窗口置顶": false,
"窗口阴影": true,
"全屏": false
}
}
```
### 3. 构建项目
使用 Visual Studio 或命令行构建:
```bash
dotnet build
```
### 4. 运行应用
```bash
dotnet run
```
或者直接运行编译后的可执行文件。
### 5. 发布应用
发布为独立可执行文件:
```bash
dotnet publish -c Release -r win-x64 --self-contained true
```
发布后的文件在 `bin/Release/net8.0-windows/win-x64/publish/` 目录下。
## 配置说明
### 窗口设置
- `窗口宽度` / `窗口高度`:设置窗口的初始大小(像素)
- `窗口可调整大小`:是否允许用户调整窗口大小
- `窗口置顶`:窗口是否始终显示在其他窗口之上
- `窗口阴影`:是否显示窗口阴影效果
- `全屏`:是否以全屏模式启动
### 注入设置
- `隐藏网页滚动条`:隐藏网页内的滚动条
- `自定义右键菜单`:启用自定义右键菜单(包含返回、刷新、关于等功能)
- `拦截下载链接`:拦截网页中的下载链接,使用系统保存对话框
### 本地网页展示模式
- `http服务器`:使用内置 HTTP 服务器提供网页服务(推荐)
- `直接本地`:直接使用 `file://` 协议加载本地文件
## 项目结构
```
WebToApp/
├── config/ # 配置文件目录
│ ├── config.json # 主配置文件
│ ├── logo.png # 应用图标
│ └── aboutpage/ # 关于页面
│ └── about.html
├── MainWindow.xaml # 主窗口 XAML
├── MainWindow.xaml.cs # 主窗口代码
├── App.xaml # 应用程序 XAML
├── App.xaml.cs # 应用程序代码
└── WebToApp.csproj # 项目文件
```
## 开发
### 环境要求
- Visual Studio 2022 或更高版本
- .NET 8.0 SDK
### 依赖项
- Microsoft.Web.WebView2 (1.0.3595.46)
## 许可证
本项目采用 MIT 许可证。
## 贡献
欢迎提交 Issue 和 Pull Request
## 作者
[你的名字]
---
如果这个项目对你有帮助,请给个 ⭐ Star 支持一下!

45
WebToApp.csproj Normal file
View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<!-- 发布Release配置下的体积优化设置 -->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<!-- 仅发布到 Windows x64避免跨平台运行时被包含 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!-- 避免 ReadyToRun 导致体积增大(换取更小体积) -->
<PublishReadyToRun>false</PublishReadyToRun>
<!-- WPF 不支持/不推荐修剪,避免 NETSDK1168 错误 -->
<PublishTrimmed>false</PublishTrimmed>
<!-- 使用不变全球化,去掉 ICU 数据(如不依赖复杂本地化可开启) -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- 去掉调试符号和文档以减少发布体积 -->
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- 单文件中尽量不自解压本机库(进一步缩减发布内容) -->
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3595.46" />
</ItemGroup>
<ItemGroup>
<Content Include="config\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

22
WebToApp.sln Normal file
View File

@@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.12.35527.113 d17.12
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebToApp", "WebToApp.csproj", "{ED8C8E3D-BAB9-4937-A38B-E937B87F5CB3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{ED8C8E3D-BAB9-4937-A38B-E937B87F5CB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ED8C8E3D-BAB9-4937-A38B-E937B87F5CB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ED8C8E3D-BAB9-4937-A38B-E937B87F5CB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ED8C8E3D-BAB9-4937-A38B-E937B87F5CB3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>关于 - WebToApp 示例</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px; }
h1 { margin-bottom: 8px; }
p { line-height: 1.6; }
.note { padding: 12px; background: #f5f5f5; border-radius: 8px; }
</style>
</head>
<body>
<h1>关于</h1>
<p>这是用于演示的关于页面,展示从应用内打开本地 HTML 的能力。</p>
<div class="note">
<p>本应用由 WPF + WebView2 构建,支持:</p>
<ul>
<li>加载在线或本地网页</li>
<li>自定义右键菜单</li>
<li>拦截并保存 data/blob/同域下载</li>
</ul>
</div>
</body>
</html>

27
config/config.json Normal file

File diff suppressed because one or more lines are too long

BIN
config/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

0
dotnet Normal file
View File

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>关于 - WebToApp 示例</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px; }
h1 { margin-bottom: 8px; }
p { line-height: 1.6; }
.note { padding: 12px; background: #f5f5f5; border-radius: 8px; }
</style>
</head>
<body>
<h1>关于</h1>
<p>这是用于演示的关于页面,展示从应用内打开本地 HTML 的能力。</p>
<div class="note">
<p>本应用由 WPF + WebView2 构建,支持:</p>
<ul>
<li>加载在线或本地网页</li>
<li>自定义右键菜单</li>
<li>拦截并保存 data/blob/同域下载</li>
</ul>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>关于 - WebToApp 示例</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px; }
h1 { margin-bottom: 8px; }
p { line-height: 1.6; }
.note { padding: 12px; background: #f5f5f5; border-radius: 8px; }
</style>
</head>
<body>
<h1>关于</h1>
<p>这是用于演示的关于页面,展示从应用内打开本地 HTML 的能力。</p>
<div class="note">
<p>本应用由 WPF + WebView2 构建,支持:</p>
<ul>
<li>加载在线或本地网页</li>
<li>自定义右键菜单</li>
<li>拦截并保存 data/blob/同域下载</li>
</ul>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAABh0RVh0Q3JlYXRpb24gVGltZQAyMDI1LTAxLTAxVDEyOjAwOjAwW7vWJQAAAQ1JREFUeNrs1kENgDAMQ9G9/5+S0YF4pG3lI1oIuJmYQ8NwJdC0xgq7r9q+3QAAACcWcH9yJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABgqY7tJwAAABi8BfC8YwAAAOw3oQIAAAD8fV8AAAAAANoYAwAAAPx9XwAAAADaGAMAAAD8fV8AAAAA2hgDAAAA/H1fAAAAANoYAwAAAPx9XwAAAADaGAMAAAD8fV8AAAAA2vgCkHfXfVQyAAAAAElFTkSuQmCC

View File

@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>本地网页示例</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px; }
h1 { margin-bottom: 8px; }
.card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin: 12px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
button, a.btn { display: inline-block; padding: 8px 14px; border-radius: 8px; border: 1px solid #ccc; text-decoration: none; color: #333; background: #f7f7f7; margin-right: 8px; }
button:hover, a.btn:hover { background: #eee; }
pre { background: #f5f5f5; padding: 12px; border-radius: 8px; overflow: auto; }
</style>
</head>
<body>
<h1>本地网页示例</h1>
<p>用于演示右键菜单、下载拦截data/blob/同域)、关于页面。</p>
<div class="card">
<h3>同域下载(静态文件)</h3>
<p>
<a class="btn" href="sample.txt" download>下载文本 sample.txt</a>
<a class="btn" href="images/sample.png" download>下载图片 sample.png</a>
</p>
</div>
<div class="card">
<h3>Data URL 下载(内联文本)</h3>
<button onclick="downloadDataUrl()">下载 data: 文本</button>
<script>
function downloadDataUrl(){
const content = encodeURIComponent('这是来自 data:URL 的文本内容\nHello WebView2!');
const url = 'data:text/plain;charset=utf-8,' + content;
const a = document.createElement('a');
a.href = url; a.download = 'data_text.txt';
a.click(); a.remove();
}
</script>
</div>
<div class="card">
<h3>Blob URL 下载(运行时生成)</h3>
<button onclick="downloadBlob()">下载 blob: 文本</button>
<script>
function downloadBlob(){
const blob = new Blob(['这是来自 blob:URL 的文本内容\nBlob Download Demo'], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'blob_text.txt';
a.click(); a.remove(); URL.revokeObjectURL(url);
}
</script>
</div>
<div class="card">
<h3>右键菜单测试</h3>
<p>在页面空白处点击右键,弹出自定义菜单(返回/刷新/关于)。</p>
</div>
<div class="card">
<h3>关于页面</h3>
<a class="btn" href="#" onclick="openAbout()">打开关于</a>
<script>
function openAbout(){
if (window.chrome && window.chrome.webview) {
window.chrome.webview.postMessage({ type: 'show_about' });
} else {
window.open('about.html', '_blank');
}
}
</script>
</div>
<div class="card">
<h3>示例文件内容</h3>
<pre>
sample.txt 将在同目录下提供。
images/sample.png 将作为占位图片。
</pre>
</div>
</body>
</html>

View File

@@ -0,0 +1,2 @@
这是一个用于同域下载的示例文本文件。
Hello from WebToApp (WPF + WebView2).

6
构建命令.txt Normal file
View File

@@ -0,0 +1,6 @@
dotnet publish -c Release -r win-x64 --self-contained false
dotnet publish -c Release -r win-x64 --self-contained true
dotnet build -c Debug

View File