Web 网站打包桌面应用的另一种方式,超级简单

灵魂就一行命令
先说最核心的东西。你有一个网页,想让它看起来像个桌面软件,其实浏览器自己就带了这个功能。打开 CMD,敲一行:
msedge --app=https://你的网页地址
或者:
chrome --app=https://你的网页地址
回车。你会看到浏览器打开了你的网页,但没有地址栏、没有标签页、没有扩展图标,窗口干干净净,看起来就是一个独立的桌面软件。
这就是整个方案的灵魂。不需要 Electron,不需要 Tauri,不需要打包工具。浏览器自己就有这个能力,只是没人告诉你。
--app 这个参数,是 Chromium 系浏览器(Chrome、Edge)内置的"应用模式"开关。加上它,浏览器就"脱掉外壳",变成一个纯粹的网页容器。
如果你只想要一个桌面化的网页,到这里就结束了。把这行命令做成一个快捷方式,双击就能用。
但如果想要更多
光有一行命令,还有几个问题:
- 全屏 :默认不是全屏,需要加
--start-fullscreen。 - 关闭窗口后进程残留:浏览器关了,但 CMD 窗口还在,或者反过来。
- 想控制本地硬件:网页没法直接调打印机、读卡器、串口设备。
所以我们在这一行命令的基础上,包一个小程序。程序干三件事:
- 用这行命令启动浏览器。
- 盯着浏览器窗口,窗口关了程序就退出。
- (可选)后台开一个小服务器,让网页能控制本地硬件。
最终成品长什么样
用户那边:双击一个 exe 图标,屏幕全屏打开你的网页。没有地址栏、没有标签页、没有浏览器外壳,看起来就是个正经的 Windows 软件。关掉窗口,程序自动退出,任务管理器里干干净净。
开发者那边:一个 exe 文件,几十 KB。不需要装运行时,不需要装 WebView2,不需要学 Rust,Windows 自带 Edge 就能跑。
技术栈:一个 C# 控制台程序,大概 150 行(纯套壳)到 300 行(带本地硬件控制)。
一、整体结构
程序分两块,跑在两个线程上:
┌──────────────────────────────────────────────┐
│ 主线程 │
│ 1. 用浏览器打开网页(--app 模式,去掉外壳) │
│ 2. 找到浏览器窗口的"身份证号" │
│ 3. 强制全屏(兜底) │
│ 4. 循环等待,窗口关了 → 退出程序 │
│ │
│ 后台线程(可选,只有需要控制硬件时才加) │
│ 监听 localhost:8033 │
│ 收到网页请求 → 执行本地操作 → 返回结果 │
└──────────────────────────────────────────────┘
主线程负责"界面",后台线程负责"干活"。如果你只是想把网页变成桌面软件,不碰硬件,那后台线程那部分完全可以不要,代码直接砍掉一半。
二、第一步:用浏览器打开网页,去掉外壳
这是最核心的一步,就是把开头那行 CMD 命令搬到代码里:
csharp
Process.Start(new ProcessStartInfo
{
FileName = browserPath,
Arguments = $"--app={url} --start-fullscreen",
UseShellExecute = false
});
--app=网址 是灵魂,--start-fullscreen 让它启动就全屏。
browserPath 是浏览器的路径。程序从注册表里找:
csharp
string[] regPaths = {
@"SOFTWARE\Clients\StartMenuInternet",
@"SOFTWARE\WOW6432Node\Clients\StartMenuInternet"
};
这两条注册表路径里记录了系统里装了哪些浏览器、装在哪。优先找 Edge,找不到再找 Chrome。这样用户不需要自己配路径,程序自己就能找到浏览器。
三、第二步:找到浏览器窗口,盯着它
浏览器启动后,我们要找到它的窗口。每个窗口在 Windows 里都有一个"身份证号"(叫窗口句柄),找到这个号之后,我们就能盯着它------号失效了(窗口被关了),程序就退出。
先记录启动前已有的浏览器窗口:
csharp
var before = new HashSet<IntPtr>();
EnumWindows((hWnd, lParam) =>
{
if (IsBrowserWindow(hWnd)) before.Add(hWnd);
return true;
}, IntPtr.Zero);
EnumWindows 是 Windows 提供的 API,能列出当前所有窗口。IsBrowserWindow 通过窗口的"类名"来判断是不是浏览器窗口:
csharp
static bool IsBrowserWindow(IntPtr hWnd)
{
StringBuilder className = new StringBuilder(256);
GetClassName(hWnd, className, 256);
return className.ToString() == "Chrome_WidgetWin_1";
}
Chrome_WidgetWin_1 是 Chromium 系浏览器(Chrome、Edge)窗口的固定类名。
再启动浏览器,然后轮询找新窗口:
csharp
for (int i = 0; i < 50; i++) // 最多等 10 秒
{
Thread.Sleep(200);
EnumWindows((hWnd, lParam) =>
{
if (IsBrowserWindow(hWnd) && !before.Contains(hWnd))
{
targetWindow = hWnd;
return false;
}
return true;
}, IntPtr.Zero);
if (targetWindow != IntPtr.Zero) break;
}
逻辑很简单:新出现的、不在 before 里的那个浏览器窗口,就是我们要找的。
强制全屏(兜底):
--start-fullscreen 有时候会被浏览器忽略。所以再加一层保险,用 Windows API 强制把窗口拉到满屏:
csharp
static void ForceFullScreen(IntPtr hWnd)
{
ShowWindow(hWnd, SW_RESTORE);
int screenW = GetSystemMetrics(SM_CXSCREEN);
int screenH = GetSystemMetrics(SM_CYSCREEN);
SetWindowPos(hWnd, IntPtr.Zero,
0, 0, screenW, screenH,
SWP_NOZORDER | SWP_NOACTIVATE);
}
GetSystemMetrics 拿屏幕宽高,SetWindowPos 把窗口设成屏幕大小。注意 GetSystemMetrics 返回值是 int,不是 IntPtr,写错了会编译报错。
循环等待,窗口关了退出:
csharp
while (IsWindow(targetWindow))
{
Thread.Sleep(100);
}
Environment.Exit(0);
Environment.Exit(0) 会强制结束所有线程,干净退出。
四、第三步(可选):让网页能控制本地硬件
到上面为止,网页已经变成一个"桌面软件"了。但如果你想让它还能调用本地硬件(比如开钱箱、读卡器、串口设备),就需要在程序里加一个小服务器。
你可以把它想象成"你家门口的一个小信箱"。网页只要往这个信箱里塞一张纸条(发个 HTTP 请求),程序就打开信箱看一眼,然后去干活。
用 HttpListener 起服务。HttpListener 是 .NET 自带的类,专门用来快速搭这种小服务器:
csharp
HttpListener listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8033/");
listener.Start();
localhost 就是"本机",8033 是门牌号,随便起的。这样网页访问 http://localhost:8033 就能找到这个信箱。
然后循环 GetContext(),每来一个请求就丢给线程池处理:
csharp
while (true)
{
HttpListenerContext ctx = listener.GetContext();
ThreadPool.QueueUserWorkItem(_ => Handle(ctx));
}
4.1 处理请求时,先要过浏览器这一关
网页和本机服务是"两个不同的地方",浏览器默认不让它们互相访问。这就像陌生网站让你打开你家保险箱,浏览器当然要拦着。所以要在回复里盖上几个"许可证"章:
csharp
string origin = req.Headers["Origin"];
if (!string.IsNullOrEmpty(origin))
{
res.Headers.Set("Access-Control-Allow-Origin", origin);
res.Headers.Set("Vary", "Origin");
}
res.Headers.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.Headers.Set("Access-Control-Allow-Headers", "*");
注意 Access-Control-Allow-Origin 必须回显请求的 Origin,不能写 *。这是 Chrome 的硬性规定。
浏览器在真正发请求之前,还会先发一个 OPTIONS 请求来"探路",这叫预检。我们要在预检里额外返回一个章:
csharp
if (req.HttpMethod == "OPTIONS")
{
res.StatusCode = 200;
if (req.Headers["Access-Control-Request-Private-Network"] == "true")
{
res.Headers.Set("Access-Control-Allow-Private-Network", "true");
}
res.Close();
return;
}
Access-Control-Allow-Private-Network 这个头是 Chrome 新加的,专门管"公网网页能不能访问本机服务"。不加这个,浏览器直接拦死。
4.2 然后才是执行本地操作
以开钱箱为例,从网址里拿 ip 参数,连打印机的 9100 端口,发那 5 个字节:
csharp
string ip = req.QueryString["ip"];
if (string.IsNullOrEmpty(ip)) ip = "192.168.100.192";
using (TcpClient client = new TcpClient())
{
IAsyncResult ar = client.BeginConnect(ip, 9100, null, null);
if (!ar.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(5)))
{
WriteJson(res, 500, false, "连接失败: 超时");
return;
}
client.EndConnect(ar);
NetworkStream stream = client.GetStream();
byte[] cmd = new byte[] { 0x1B, 0x70, 0x00, 0x50, 0x50 };
stream.Write(cmd, 0, cmd.Length);
stream.Flush();
}
这 5 个字节是热敏打印机行业的标准指令,意思是"让钱箱弹开一下"。几乎所有网络打印机都认这条。
其他硬件(读卡器、串口设备、扫码枪)也是同样的套路:网页发请求 → 程序调对应的 Windows API 或驱动 → 返回结果。
4.3 最后返回结果
csharp
WriteJson(res, 200, true, "开箱成功");
返回一个 JSON 格式的结果,网页那边就能知道成功还是失败。
五、完整代码
csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Windows.Forms;
class Program
{
// ==================== Win32 API ====================
[DllImport("user32.dll")]
static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll")]
static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
static extern int GetSystemMetrics(int nIndex);
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
const int SW_RESTORE = 9;
const int SM_CXSCREEN = 0;
const int SM_CYSCREEN = 1;
const uint SWP_NOZORDER = 0x0004;
const uint SWP_NOACTIVATE = 0x0010;
// ==================== 入口 ====================
[STAThread]
static void Main()
{
// 如果不需要控制本地硬件,这一行可以删掉
PrintService.Start();
OpenApp();
}
// ==================== 启动器 ====================
static void OpenApp()
{
string url = "https://你的网页地址/";
string browserPath = FindBrowser();
if (browserPath == null)
{
MessageBox.Show("未找到浏览器");
return;
}
var before = new HashSet<IntPtr>();
EnumWindows((hWnd, lParam) =>
{
if (IsBrowserWindow(hWnd)) before.Add(hWnd);
return true;
}, IntPtr.Zero);
Process.Start(new ProcessStartInfo
{
FileName = browserPath,
Arguments = $"--app={url} --start-fullscreen",
UseShellExecute = false
});
IntPtr targetWindow = IntPtr.Zero;
for (int i = 0; i < 50; i++)
{
Thread.Sleep(200);
EnumWindows((hWnd, lParam) =>
{
if (IsBrowserWindow(hWnd) && !before.Contains(hWnd))
{
targetWindow = hWnd;
return false;
}
return true;
}, IntPtr.Zero);
if (targetWindow != IntPtr.Zero) break;
}
if (targetWindow == IntPtr.Zero) return;
Thread.Sleep(500);
ForceFullScreen(targetWindow);
while (IsWindow(targetWindow))
{
Thread.Sleep(100);
}
Environment.Exit(0);
}
static void ForceFullScreen(IntPtr hWnd)
{
ShowWindow(hWnd, SW_RESTORE);
int screenW = GetSystemMetrics(SM_CXSCREEN);
int screenH = GetSystemMetrics(SM_CYSCREEN);
SetWindowPos(hWnd, IntPtr.Zero,
0, 0, screenW, screenH,
SWP_NOZORDER | SWP_NOACTIVATE);
}
static bool IsBrowserWindow(IntPtr hWnd)
{
StringBuilder className = new StringBuilder(256);
GetClassName(hWnd, className, 256);
return className.ToString() == "Chrome_WidgetWin_1";
}
static string FindBrowser()
{
string[] keywords = { "Edge", "Chrome" };
foreach (string keyword in keywords)
{
string path = FindBrowserByKeyword(keyword);
if (path != null) return path;
}
return null;
}
static string FindBrowserByKeyword(string keyword)
{
string[] regPaths = {
@"SOFTWARE\Clients\StartMenuInternet",
@"SOFTWARE\WOW6432Node\Clients\StartMenuInternet"
};
foreach (string regPath in regPaths)
{
using (Microsoft.Win32.RegistryKey root =
Microsoft.Win32.Registry.LocalMachine.OpenSubKey(regPath))
{
if (root == null) continue;
foreach (string subKeyName in root.GetSubKeyNames())
{
if (subKeyName.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) < 0)
continue;
using (Microsoft.Win32.RegistryKey cmdKey =
root.OpenSubKey(subKeyName + @"\shell\open\command"))
{
if (cmdKey == null) continue;
string cmd = cmdKey.GetValue(null) as string;
if (string.IsNullOrEmpty(cmd)) continue;
if (cmd.StartsWith("\""))
{
int end = cmd.IndexOf('"', 1);
if (end > 0) return cmd.Substring(1, end - 1);
}
else
{
int space = cmd.IndexOf(' ');
return space > 0 ? cmd.Substring(0, space) : cmd;
}
}
}
}
}
return null;
}
}
// ==================== 本地服务(可选) ====================
// 只是把网页变桌面软件的话,整个 PrintService 类都可以删掉
class PrintService
{
public static void Start()
{
Thread t = new Thread(() =>
{
HttpListener listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8033/");
try
{
listener.Start();
}
catch
{
return;
}
while (true)
{
try
{
HttpListenerContext ctx = listener.GetContext();
ThreadPool.QueueUserWorkItem(_ => Handle(ctx));
}
catch { }
}
});
t.IsBackground = true;
t.Start();
}
static void Handle(HttpListenerContext ctx)
{
HttpListenerRequest req = ctx.Request;
HttpListenerResponse res = ctx.Response;
string origin = req.Headers["Origin"];
if (!string.IsNullOrEmpty(origin))
{
res.Headers.Set("Access-Control-Allow-Origin", origin);
res.Headers.Set("Vary", "Origin");
}
else
{
res.Headers.Set("Access-Control-Allow-Origin", "*");
}
res.Headers.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.Headers.Set("Access-Control-Allow-Headers", "*");
if (req.HttpMethod == "OPTIONS")
{
res.StatusCode = 200;
if (req.Headers["Access-Control-Request-Private-Network"] == "true")
{
res.Headers.Set("Access-Control-Allow-Private-Network", "true");
}
res.Close();
return;
}
string ip = req.QueryString["ip"];
if (string.IsNullOrEmpty(ip)) ip = "192.168.100.192";
try
{
using (TcpClient client = new TcpClient())
{
IAsyncResult ar = client.BeginConnect(ip, 9100, null, null);
if (!ar.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(5)))
{
WriteJson(res, 500, false, "连接失败: 超时");
return;
}
client.EndConnect(ar);
NetworkStream stream = client.GetStream();
byte[] cmd = new byte[] { 0x1B, 0x70, 0x00, 0x50, 0x50 };
stream.Write(cmd, 0, cmd.Length);
stream.Flush();
}
WriteJson(res, 200, true, "开箱成功");
}
catch (Exception ex)
{
WriteJson(res, 500, false, "发送失败: " + ex.Message);
}
}
static void WriteJson(HttpListenerResponse res, int status, bool success, string message)
{
res.StatusCode = status;
res.ContentType = "application/json; charset=utf-8";
string json = "{\"success\":" + (success ? "true" : "false")
+ ",\"message\":\"" + EscapeJson(message) + "\"}";
byte[] buffer = Encoding.UTF8.GetBytes(json);
res.ContentLength64 = buffer.Length;
res.OutputStream.Write(buffer, 0, buffer.Length);
res.Close();
}
static string EscapeJson(string s)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "\\r")
.Replace("\n", "\\n");
}
}
六、几个容易踩的坑
坑 1:--start-fullscreen 有时不生效
部分 Chromium 版本 + --app 组合下会忽略这个参数。所以加了 ForceFullScreen 兜底。如果还不满意,可以把参数换成 --kiosk,那个更彻底------连 F11 退出都不给。
坑 2:GetSystemMetrics 返回值是 int,不是 IntPtr
csharp
[DllImport("user32.dll")]
static extern int GetSystemMetrics(int nIndex); // 对
写成 IntPtr 会编译报错。
坑 3:Access-Control-Allow-Origin 不能写 *
只在你要加"网页控制本地硬件"功能时才会遇到。写 * 的话,Chrome 会认为"你允许所有网站来访问本机服务",这在安全上是不允许的,直接拦死。必须回显请求的 Origin。
坑 4:Chrome 会弹权限提示
即使服务端所有头都对了,Chrome 新版本还会弹一个"是否允许访问本机设备"的提示,用户必须手动点"允许"。这个没法绕过,是浏览器的安全底线。
坑 5:HttpListener 监听 localhost 不需要管理员权限
但如果改成 http://+:8033/ 或 http://*:8033/,就需要管理员权限或者提前 netsh http add urlacl。所以老老实实用 localhost。
七、总结
灵魂就一行 CMD 命令:
msedge --app=https://你的网页地址
如果你只是想把网页变成桌面软件,核心就两件事:
- 用
--app参数启动浏览器,去掉地址栏和标签页。 - 盯着这个窗口,窗口关了程序就退出。
代码量大概 150 行,PrintService 整个类可以删掉。
如果你还想让网页控制本地硬件,再加第三件事:
- 后台开个"信箱" (
HttpListener监听 8033),网页发 HTTP 请求过来,程序执行本地操作。
代码量翻倍到 300 行。