简介:面向 Web 与 Windows 桌面混合开发场景的示例包,聚焦浏览器唤起本地程序这一常见需求,适合需要实现网页与本地应用交互的前端、桌面端开发者及技术学习者参考。压缩包共 4 个文件,约 189KB,含注册表配置、HTML 调用页面、本地可执行程序与说明文档:注册表项用于登记自定义 URL 协议,HTML 页面负责发起调用,可执行程序承接请求,说明文档交代使用方式与注意事项,形成从协议注册到实际调用的最小闭环。已有 454 人学习下载。读者可借助这套轻量样例理解自定义协议处理程序的基本机制,观察网页链接如何触发本地程序,并据此改造成设备配置、客户端拉起或内部系统集成方案;也可作为排查协议未注册、浏览器拦截等问题的对照素材,快速搭建可验证的实验环境。
1. 网页唤起本地程序:中间人不可避免
财务同事在浏览器里点了一下「打印凭证」,本地那台专用打印机就开始出纸——网页和本地程序被浏览器沙箱隔开,谁都没法直接调用谁。web 调用本地应用程序这件事,本质上是给两边找一个合规的中间人:页面负责发指令,本地代理负责执行,两边约定一套报文格式。这个 zip 给的就是这样一套最小可跑的样例,一个静态页面加一个常驻托盘的本地代理,用自定义 URL 协议做唤起、用 WebSocket 做回传,把「唤起、传参、执行、回执」四个环节串成了一条完整链路。它适合正在做内部系统对接本地硬件、想给网页加「一键打开本地客户端」能力的开发者,也适合想搞清楚浏览器安全边界到底卡在哪、不想在 Electron 和浏览器扩展之间反复纠结的人。
2. 整体架构:从浏览器沙箱到本地进程的三段式链路
这套东西看着简单,拆开是三段:页面侧发起、系统层转发、代理侧执行。想清楚每一段各自的责任边界,后面调试才不会一遇到问题就去改错地方。很多人第一次做这类需求,直接把「唤起客户端」和「让客户端干活并返回结果」当成一件事,结果卡在「客户端起来了,但页面拿不到结果」上——这两件事在浏览器里必须由两条不同的通道完成。
2.1 三条链路的取舍:URL 协议、本地服务、浏览器扩展
能实现 web 调本地程序的路子就那么几条,先把取舍摆清楚,比后面写代码重要得多。
| 链路 | 唤起方式 | 能否回传结果 | 落地成本 | 典型场景 |
|---|---|---|---|---|
| 自定义 URL 协议 | location.href或隐藏 iframe 跳localbridge:// | 不能,必须另开通道 | 低,注册表两行 | 唤起客户端、打开本地文件 |
| 本地 HTTP / WebSocket 服务 | 页面直连127.0.0.1:端口 | 能,天然双向 | 中,要处理端口、单实例、Origin 校验 | 需要拿到执行结果的业务 |
| 浏览器扩展原生消息 | 扩展 background 转发 | 能 | 高,要装扩展、要过商店审核 | 面向公众的产品 |
这份资源选的是「协议唤起 + WebSocket 回传」的混合方案,理由很实际:协议几乎是零成本的唤醒手段,用户点了就有反应,不需要事先知道代理有没有起来;但协议是单向的,页面发出去就断了,执行结果必须靠第二条长连接送回来。两条通道各管一半,比硬做一条通道省心得多。内部系统对接打印、扫码枪、读卡器这类场景,这个组合基本是性价比最高的那一档。
有个前提得说清楚:代理必须先跑起来,长连接才连得上。所以真实流程是「先试连,连不上再唤起,唤起后等一会儿重连」,顺序不能反。反过来写,用户每点一次都要多等两秒弹窗。
2.2 目录结构与启动顺序
压缩包解开之后没有构建步骤,纯静态页面加一个已经编译好的桌面程序,改代码不用装 Node 环境,这点对现场调试很友好。
| 路径 | 作用 | 启动顺序 |
|---|---|---|
agent/LocalBridge.exe | 托盘常驻代理,监听本地端口 | 1 |
agent/config.json | 端口段、允许来源、动作白名单开关 | 1 |
tools/register-protocol.reg | 手动注册localbridge://协议 | 2(管理员身份双击) |
web/index.html | 演示页,三个按钮对应三种动作 | 3 |
web/bridge.js | 前端调用层,封装连接、唤起、Promise 化 | 3 |
docs/protocol.md | 报文格式与错误码,改造前必读 | 随时 |
logs/bridge-yyyyMMdd.log | 代理侧滚动日志,排查全靠它 | 自动生成 |
启动顺序固定为:代理起来 → 绑定端口并写端口文件 → 首次运行写注册表 → 浏览器打开页面 → 页面连上 WebSocket 发一次ping。这五步里任何一步跳过,后面都会以「看起来什么都没发生」的形式报错,所以第一次跑建议手动一步步来,别直接用脚本一键拉起。
代理绑定端口的策略是「固定段 + 向上试探」:默认从9527开始,被占用就试9528,最多试到9536,最终端口写进%LOCALAPPDATA%\LocalBridge\port.txt。页面侧连不上9527就依次往后试,这个笨办法在只有一台机器的场景里非常稳,比动态端口加回传机制简单一个数量级。
2.3 通信协议设计:请求/响应报文与错误码
协议别搞复杂,一条 JSON 一行,seq做请求响应配对,就够了。代理侧收到一行、处理完、回一行,中间不搞多路复用和流式传输。
{ "seq": 17, "action": "print", "params": { "file": "D:\\tmp\\voucher-204.pdf", "printer": "默认" }, "ts": 1730000000000 }seq是页面自增的请求号,代理必须原样带回,页面靠它把响应分发给对应的 Promise;action是动作名,只允许出现在白名单里的值;params是扁平对象,值只接受字符串、数字、布尔,不嵌套复杂结构,避免两端解析方式不一致;ts是页面时间戳,代理侧拿它和本地时间比对,超过 30 秒的旧请求直接丢弃,防止用户切走标签页半小时后突然收到一堆迟到结果。
返回报文:
{ "seq": 17, "ok": true, "code": 0, "data": { "jobId": "P-204", "cost": 132 }, "msg": "" }code这套错误码是全篇最该先记住的东西,联调时九成的问题都能从这个数字定位到。
| code | 含义 | 常见触发点 |
|---|---|---|
| 0 | 成功 | 正常返回 |
| 1001 | 未知动作 | action写了但白名单里没有 |
| 1002 | 参数缺失或非法 | 必填字段为空、路径越界 |
| 1003 | 无权限 | 目标文件不可读、打印机未授权 |
| 1004 | 执行超时 | 打印任务阻塞、外部设备无响应 |
| 1005 | 通道未就绪 | 代理已唤醒但 WebSocket 还没连上 |
看到1001别去查网络,去查config.json的白名单;看到1005别去查动作实现,去看代理进程是不是刚起来还没绑定端口。这个分工意识建立起来之后,排查速度会快很多。
3. 网页侧实现:把指令送出去并等到结果
页面侧要做的事只有三件:判断代理在不在线、不在线就唤起、发请求等回执。听起来简单,但每一步都有浏览器行为上的坑,写完能跑和写完能稳定跑是两码事。
3.1 自定义协议唤起:URL 拼接与参数转义
唤起的核心就是拼一个localbridge://run?action=xxx&...然后让浏览器去跳转。跳转方式有两种,隐藏 iframe 更稳,因为当前页面不会被导航走。
// 拼接协议 URL 并唤起本地代理 function wakeup(action, params = {}) { // URLSearchParams 会自动做 encodeURIComponent,中文和 & 不会截断参数 const query = new URLSearchParams({ action, ...params }).toString(); const url = `localbridge://run?${query}`; const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = url; document.body.appendChild(iframe); // 3 秒后清理,避免页面里堆积一堆不可见节点 setTimeout(() => iframe.remove(), 3000); }这里最关键的不是 iframe,而是URLSearchParams那一步。手工拼字符串是血泪教训的高发区:文件路径里带&、中文文件名、带空格的打印机名,任何一项没转义,代理收到的参数就是被腰斩的。函数参数action必须和白名单里的键完全一致,params里的值统一转成字符串,数字和布尔在代理侧再解析。
还有一个必须知道的浏览器行为:部分版本对「非用户手势触发的自定义协议跳转」会静默拦截,控制台什么都不打。所以wakeup一定要绑在按钮点击回调里,不要放在setTimeout或者onload里,否则会出现「本地手动执行命令能唤起、页面上点了没反应」这种很难自证的现象。
3.2 WebSocket 通道:连接、心跳与退避重连
长连接是拿到结果的唯一途径,因此它的健壮性直接决定整个方案的体验。核心是三件事:连接超时、心跳保活、断线重连。
// 在 9527~9536 之间找代理监听的端口 async function findAgent(timeout = 600) { for (let port = 9527; port <= 9536; port++) { const ok = await tryConnect(port, timeout); if (ok) return ok; } return null; } function tryConnect(port, timeout) { return new Promise((resolve) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/bridge`); const timer = setTimeout(() => { ws.close(); resolve(null); }, timeout); ws.onopen = () => { clearTimeout(timer); resolve(ws); }; ws.onerror = () => { clearTimeout(timer); resolve(null); }; }); }timeout设成 600 毫秒是有讲究的:本地回环连接要么秒通要么秒拒,超过 600 毫秒基本就是端口没人听,再等下去只是让用户干瞪眼。整个扫描最多花 6 秒,但实际绝大多数情况第一个端口就命中,用户感知不到延迟。
连上之后立刻挂心跳,别指望 TCP 自己告诉你对端死了——代理被任务管理器杀掉、机器休眠唤醒,这些场景下onclose可能几分钟都不触发。
let heartbeat = null; function startHeartbeat(ws) { clearInterval(heartbeat); let miss = 0; heartbeat = setInterval(() => { if (ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ seq: 0, action: 'ping', params: {}, ts: Date.now() })); if (++miss >= 3) { ws.close(); } // 连续 3 次没回音,判定假死 }, 20000); }心跳间隔 20 秒、容错 3 次,是折中值。间隔太短会刷日志,太长则假死检测迟钝。注意ping的seq固定为 0,代理侧遇到seq: 0只回不回显,也不进业务日志,避免心跳把日志淹掉。重连用指数退避,500、1000、2000、4000 封顶,不要写死 1 秒轮询——那会在代理崩溃时把浏览器 CPU 拉满。
3.3 调用层封装:把回调包成 Promise
业务代码不该关心底层是协议还是长连接,所以要在最外面包一层,对外只暴露invoke(action, params)。
const pending = new Map(); // seq -> { resolve, reject, timer } let seq = 0; async function invoke(action, params = {}, { timeout = 8000 } = {}) { let ws = currentSocket(); if (!ws) { ws = await findAgent(); if (!ws) { // 代理没起来,先唤起再等它上线 wakeup(action, params); ws = await waitForAgent(6000); // 6 秒内轮询,等托盘程序自启完成 if (!ws) throw new Error('AGENT_OFFLINE'); } } const id = ++seq; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(Object.assign(new Error('TIMEOUT'), { code: 1004 })); }, timeout); pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ seq: id, action, params, ts: Date.now() })); }); }参数说明一下:timeout默认 8 秒,覆盖打印这类需要排队动作的常规耗时;waitForAgent内部是 200 毫秒一次的轮询,最多 30 次,也就是 6 秒,正好覆盖托盘程序冷启动的时间。pending这个 Map 是这套封装的记忆点,请求发出后如果一直不清理,页面开一天会积几百个永不 resolve 的 Promise,内存和体验双输,所以超时回调里必须delete。
对应的onmessage就三行逻辑:解析、按seq取回pending条目、清定时器后按ok决定 resolve 还是 reject。这段代码短,但它是整条链路的收口,任何一处疏漏都会表现成「接口偶发卡死」。
4. 本地代理端实现:托盘程序、注册表与消息路由
代理侧是这套方案里唯一碰系统的地方,也是权限、安全、稳定性问题最集中的地方。原则只有一条:网页传来的一切都不可信。
4.1 注册协议处理器:写 HKCU 而不是 HKCR
localbridge://这个协议能被浏览器识别,靠的是注册表里的一段约定。写在哪一层,决定了要不要管理员权限。
// 写在 HKCU\Software\Classes 下,普通用户权限即可生效 const string scheme = "localbridge"; string exePath = @"C:\Tools\LocalBridge\LocalBridge.exe"; using var root = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{scheme}"); root.SetValue("", "URL:LocalBridge Protocol"); root.SetValue("URL Protocol", ""); // 这一项存在,浏览器才认它是协议 using var cmd = root.CreateSubKey(@"shell\open\command"); cmd.SetValue("", $"\"{exePath}\" \"%1\""); // 路径必须带引号,%1 是完整 URL逐项说:URL Protocol这一项的值留空都行,但键必须存在,否则浏览器只当成普通链接;command里的exePath一定要用双引号包住,Program Files这种带空格的路径不加引号会被系统按空格切开,表现是「协议注册成功但点了没反应」;%1是系统替换的完整 URL,代理启动后从args[0]里取,别自己拼。
写 HKCU 的好处是全程免管理员,卸载时删掉这个键就干净了,不会在HKCR里留垃圾。带来的限制是「只对当前用户生效」,一台机器多个账号要各自注册一次,这在办公场景反而是好事。
4.2 命令分发器:白名单驱动的动作表
网页传什么就执行什么,是最危险的做法。分发器必须是一张显式的动作表,未登记的一律拒。
static readonly Dictionary<string, Func<JObject, Task<object>>> Actions = new(StringComparer.OrdinalIgnoreCase) { ["ping"] = _ => Task.FromResult<object>(new { pong = true }), ["openFile"] = p => Shell.Open(SafePath(p.Value<string>("path"))), ["print"] = p => Printer.RunAsync(p.Value<string>("file"), p.Value<string>("printer")), }; async Task<object> Dispatch(JObject req) { string action = req.Value<string>("action") ?? ""; if (!Actions.TryGetValue(action, out var handler)) return Fail(1001, "unknown action"); // 不在表里,直接拒 try { var data = await handler(req["params"] as JObject ?? new JObject()); return new { seq = req["seq"], ok = true, code = 0, data }; } catch (TimeoutException) { return Fail(1004, "timeout"); } catch (UnauthorizedAccessException) { return Fail(1003, "denied"); } catch (Exception ex) { return Fail(1002, ex.Message); } }Actions用OrdinalIgnoreCase比较器,是为了让OpenFile和openFile都能命中,减少前端大小写踩坑。SafePath是第二道闸:把传入路径做Path.GetFullPath规范化,然后检查前缀是否落在配置里的允许目录内,越界直接抛1002。这一层挡住的是..\..\Windows\System32\xxx这类路径穿越,功能上看着无关紧要,安全上却是分水岭。
每个 handler 都返回Task,是因为打印、读卡这类操作天然异步;同步阻塞在 UI 线程上会让托盘菜单整个卡住,用户以为程序崩了就会去任务管理器结束进程,然后所有请求一起超时。
4.3 单实例与命令行参数:第二次唤醒走转发
用户点第二次按钮时,系统会再启动一个LocalBridge.exe。如果不管,就会出现两个进程抢同一个端口,第二个 bind 失败、闪一下就退出,看起来像「点了没反应」。
[STAThread] static void Main(string[] args) { using var mutex = new Mutex(true, @"Local\LocalBridge.Instance", out bool isFirst); if (!isFirst) { // 已有实例:把这次唤醒的参数转发过去,然后本进程立刻退出 IpcClient.Notify(args.Length > 0 ? args[0] : ""); return; } var app = new TrayApp(args); // 首次启动,正常进入托盘 Application.Run(app); }Local\前缀表示只在当前登录会话内互斥,这个选择是对的:Global\在服务账号和普通用户混跑的环境里容易出现权限异常,而浏览器唤起永远发生在当前会话,没必要跨会话。using保证退出时释放,但要注意 Mutex 若被 GC 提前回收,单实例就失效了,所以using的作用域要覆盖整个Application.Run。
转发那一步用的是本地 IPC 而不是再开 WebSocket,因为此时端口还没确定,走进程间命名管道更直接。转发的内容是完整 URL,主实例解析后自己跑一遍Dispatch,把结果通过已建立的长连接回给页面——这样第二次点击的响应速度会和第一次完全一致。
4.4 安全边界:Origin 校验、Token 与参数白名单
本地端口一旦对外开放,同机器上的任何网页都能连,这是这套方案最容易被忽略的风险点。三道防线按顺序上。
第一道是握手时的Origin校验:WebSocket 服务端在OnOpen阶段读请求头,Origin不在config.json的allowedOrigins里就直接断开,别给它发任何报文。第二道是 Token:代理启动时生成一个随机串写进%LOCALAPPDATA%\LocalBridge\token.txt,页面通过协议唤起拿到(或者由后端下发),后续每个请求都带token字段,对不上返回1003。第三道就是前面说的参数白名单和路径规范化。
有一点必须咬死:action表里绝对不要出现「执行任意命令」「打开任意 URL」这种通用动作。这类动作一旦暴露,等于把整台机器的控制权交给了任何能打开网页的人。真要做「打开浏览器某地址」,也只能是配置里写死的几个枚举值,不接受页面传完整 URL。
5. 联调避坑:本地能跑、换台机器就翻车的五个现场
这套东西的诡异之处在于,开发机上一切正常,到用户机器上就各种静默失败。下面五条是我在实际对接里反复撞见的,按「现象 → 原因 → 解决」记下来,遇到问题可以当检查表用。
5.1 现象:点了按钮毫无反应,控制台也没有报错
原因:三种可能混在一起——协议没注册、注册了但路径带空格没加引号、或者唤起发生在非用户手势的调用栈里被浏览器拦了。
解决:先用命令行验证协议本身,绕开浏览器这层。
# 直接让系统按协议唤起,看看代理起没起 start "" "localbridge://run?action=ping"命令行能起,说明注册表没问题,回去查前端调用是不是放在await之后或者setTimeout里;命令行也起不来,用reg query "HKCU\Software\Classes\localbridge\shell\open\command"看路径有没有引号、exe 是不是被移动过。改了 exe 位置一定要重新注册,注册表里存的是绝对路径。
5.2 现象:客户端起来了,页面一直转圈直到超时
原因:页面在唤起之前就已经把「找代理」的循环跑完了,唤起之后没有重新发起连接。findAgent只扫一遍,扫不到就直接抛AGENT_OFFLINE。
解决:把「先试连 → 失败唤起 → 再试连」写成固定顺序,第二段等待至少 6 秒,且用轮询而不是一次性延时。托盘程序冷启动要加载配置、绑端口,实测 2 到 4 秒很常见,一次性延时 1 秒必然扑空。顺带检查代理日志里有没有bind failed,端口被别的程序占了也会表现成同样的现象。
5.3 现象:中文路径或文件名带&的时候,参数传过去是断的
原因:手工拼 URL,&被当成参数分隔符,中文没做百分号编码,后端解析出来只剩前半截。
解决:一律走URLSearchParams或encodeURIComponent,代理侧再用标准 URL 解码还原。千万别在两端各写一套自定义转义规则,那种「我转一次你转一次」的写法,遇到中文和特殊符号必然对不齐。改完之后用带空格、带中文、带&的路径各测一遍,三个都过才算完。
5.4 现象:装完杀毒软件之后,唤起变慢甚至有概率失败
原因:本地程序每次被协议唤起都会启动一个新进程,实时防护会扫描一遍 exe;再加上单实例没生效,进程反复创建销毁,耗时叠加。
解决:确认Mutex的作用域覆盖了整个消息循环,让第二次唤醒走 IPC 转发而不是重新启动进程;同时在安装说明里写清楚把程序目录加入信任列表。这不是代码能完全解决的问题,但把「每次点击都新起进程」改成「一次启动长期驻留」,绝大部分卡顿会消失。
5.5 现象:开发机好好的,用户机器上连不上 9527
原因:用户机器上别的内部工具占了同一个端口,或者公司策略禁了某段端口。代理 bind 失败后直接退出,页面侧只看到连不上。
解决:端口段探测加日志落盘,代理每次启动把「尝试了哪些端口、最终用了哪个、失败的报错是什么」写进日志第一行。前端扫描端口段时不要并发全开,串行试,避免触发本地防火墙的异常连接告警。真要长期维护,端口段做成配置项,遇到冲突让运维改配置比改代码快。
6. 二次开发:从「能唤起」到「能干活」的改造路径
拿到这份代码之后,绝大多数人的下一步都是加自己的动作。改法比想象中简单,但要按顺序来,跳步会浪费很多时间。
先动config.json,把allowedOrigins改成自己的域名,actions白名单里加上新动作名——不先改配置直接写代码,调试时会一直被1001挡住,容易误判成代码没生效。然后去代理侧的Actions字典里加一条 handler,参数从params里按字段名取,取不到就抛带1002的异常,别用默认值糊过去。
// 新增:读取本地设备序列号,返回给页面 ["readDeviceSn"] = async p => { string port = p.Value<string>("port") ?? "COM3"; // 串口名,页面传入 int timeoutMs = p.Value<int?>("timeoutMs") ?? 3000; // 读超时,默认 3 秒 return await SerialReader.ReadAsync(port, timeoutMs); };port和timeoutMs都给了默认值,是为了让调用方不传也能跑通,但页面侧最好显式传,串口名在不同机器上不一样。handler 里不要写Thread.Sleep,也不要吞异常返回null,null到前端会变成一个「成功但没有数据」的响应,比报错更难查。
前端这边几乎不用改,invoke('readDeviceSn', { port: 'COM4' })就能拿到结果。真正需要动的是三处:web/bridge.js里的重连策略(如果新动作耗时超过 8 秒,把timeout传大一点)、代理侧的日志埋点(每个 handler 进出各打一行,带seq和耗时)、以及docs/protocol.md里的错误码表(新增动作特有的失败原因,自己编一段号段,别复用 1001 到 1005)。
验证方法建议按这个顺序走一遍,能挡住八成回归问题:命令行直接start "" "localbridge://run?action=readDeviceSn&port=COM4"确认协议链路通;页面上连点三次确认单实例和幂等没问题;拔掉设备再点一次,确认返回的是1004而不是页面卡死;最后换一台没装过开发环境的机器完整跑一遍。第四步最容易被跳过,也最容易暴露硬编码路径和端口假设。
有个改造方向值得提前想清楚:把「同步等结果」改成「先受理再回调」。打印、扫码这类动作在真实业务里可能排队几十秒,让前端 Promise 干等不是好设计,更稳的做法是代理立刻返回一个jobId,执行完由代理主动推一条{ seq: 0, action: 'notify', data: { jobId, ok } }给页面,前端按jobId分发。这套推送通道在现有的 WebSocket 里天然就有,不需要新增基础设施。
从那以后我每次改这类本地代理,都强制自己先跑一遍「命令行唤起 → 页面唤起 → 拔设备 → 换机器」这四步再提交,跳过任何一步,都会在别人的电脑上以最尴尬的方式还回来。希望帮到你。
本文还有配套的精品资源,点击获取