Web 网站打包桌面应用的另一种方式,超级简单(C# exe 33Kb)

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

灵魂就一行命令

先说最核心的东西。你有一个网页,想让它看起来像个桌面软件,其实浏览器自己就带了这个功能。打开 CMD,敲一行:

复制代码
msedge --app=https://你的网页地址

或者:

复制代码
chrome --app=https://你的网页地址

回车。你会看到浏览器打开了你的网页,但没有地址栏、没有标签页、没有扩展图标,窗口干干净净,看起来就是一个独立的桌面软件。

这就是整个方案的灵魂。不需要 Electron,不需要 Tauri,不需要打包工具。浏览器自己就有这个能力,只是没人告诉你。

--app 这个参数,是 Chromium 系浏览器(Chrome、Edge)内置的"应用模式"开关。加上它,浏览器就"脱掉外壳",变成一个纯粹的网页容器。

如果你只想要一个桌面化的网页,到这里就结束了。把这行命令做成一个快捷方式,双击就能用。


但如果想要更多

光有一行命令,还有几个问题:

  1. 全屏 :默认不是全屏,需要加 --start-fullscreen。
  2. 关闭窗口后进程残留:浏览器关了,但 CMD 窗口还在,或者反过来。
  3. 想控制本地硬件:网页没法直接调打印机、读卡器、串口设备。

所以我们在这一行命令的基础上,包一个小程序。程序干三件事:

  • 用这行命令启动浏览器。
  • 盯着浏览器窗口,窗口关了程序就退出。
  • (可选)后台开一个小服务器,让网页能控制本地硬件。

最终成品长什么样

用户那边:双击一个 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://你的网页地址

如果你只是想把网页变成桌面软件,核心就两件事:

  1. 用 --app 参数启动浏览器,去掉地址栏和标签页。
  2. 盯着这个窗口,窗口关了程序就退出。

代码量大概 150 行,PrintService 整个类可以删掉。

如果你还想让网页控制本地硬件,再加第三件事:

  1. 后台开个"信箱" (HttpListener 监听 8033),网页发 HTTP 请求过来,程序执行本地操作。

代码量翻倍到 300 行。

相关推荐
沫璃染墨1 小时前
《从零入门Linux系统篇(五十一):线程篇·四——pthread线程库详解:从线程创建到终止与分离》
linux·运维·服务器·开发语言·c++·系统架构·线程
Tyrvision1 小时前
新品发布前,三维动画怎么排期才不挤在最后一周?
前端
yexianglunbai1 小时前
中间件(Middleware)详解:概念、作用与实战
java·开发语言·中间件
miss1 小时前
从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比
前端·vue.js·typescript
叶总没有会1 小时前
OpenFeign
java·开发语言·springcloud·openfeign
Daorigin_com1 小时前
道本科技携手DeepSeek:以AI重塑合同全生命周期管理
前端·人工智能·科技·网络安全·数据挖掘·前端框架·传媒
flash俊杰1 小时前
自动更新工程:electron-updater、差分更新、灰度发布与失败回滚
前端
fastjson_1 小时前
帆软看板 - 问题收集
linux·前端·javascript