Web调用本地exe实战:自定义URI Scheme与本地桥接服务
简介:面向Web与Windows桌面混合应用开发者的轻量示例包,聚焦浏览器页面如何唤起、配置并与本地应用程序交互,适合想了解自定义协议、注册表配置及本地程序调用链路的初中级开发者做技术验证与方案对比。压缩包共4个文件,包含1个html网页、1个exe本地程序、1个reg注册表配置和1个txt说明,整体约189KB,体量小巧但覆盖了从页面触发到本地响应的关键环节,便于在Windows环境快速搭建可运行的最小样例。已有454人学习下载,说明该主题在混合应用开发中具有稳定关注度。读者可通过网页端与本地程序的配合、注册表项的作用以及说明文档的指引,直观理解Web调用本地应用的实现思路,并据此排查配置、协议注册和调用失败等常见问题,为后续选用Electron、WebView2或自定义协议等方案提供参考。
1. 从浏览器点一下到 exe 跑起来:web 调用本地应用程序卡在哪
内部系统挂在浏览器里,业务跑到一半却要拉起本地那个 exe——读串口、盖章打印、调加密狗客户端,这些事浏览器沙箱一件都干不了。web 调用本地应用程序,在 windows 上本质就是一次跨沙箱的协商:要么让系统替你把手伸出去,要么在本机回环地址上开一条只有自己能走的通道。压缩包里给的是这两条链路的可运行样例,一套自定义 URI Scheme 的注册表脚本加启动器,一套本地桥接服务加前端封装。适合被「点了没反应」「预检 405」折磨过的后端和前端,也适合第一次接手这类需求、想照着跑通再改的同学。下面按我拆包时的顺序讲,先讲最轻的那条。
2. 协议注册表方案:自定义 URI Scheme 把网页请求转成进程启动
2.1 协议方案的边界:什么场景该用它,什么场景直接放弃
浏览器遇到一个自己不认识的 scheme(比如myapp://),不会自作主张,而是把它交给操作系统。Windows 拿到 scheme 之后去HKEY_CLASSES_ROOT或HKEY_CURRENT_USER\Software\Classes下找同名键,读shell\open\command,把%1展开成完整 URL,然后 CreateProcess。整条链路上浏览器只负责「交出去」这一个动作,交出去之后成没成功、返回了什么,它一概不知道。这一点决定了它的全部优缺点。
好处很实在:零常驻进程、不占端口、不触发防火墙提示、不依赖任何运行时,装完就生效。对只需要「把程序拉起来」的场景,这是成本最低的一条路。
代价同样明确,我一般会先摆出来让需求方确认:
- 单向。没有返回值。你没法知道用户是点了「打开」还是点了「取消」,也没法知道启动器有没有跑成功。
- 有确认框。Chrome 和 Edge 首次会弹「是否打开 xxx」,Firefox 还会带一个记住选择的勾选。用户手一抖点了取消,链路就断在这里,前端看不到任何异常。
- 参数走命令行。URL 必须编码,中文、空格、
&、#都容易在这儿炸;长度也受系统命令行的约束,别指望靠它传文件。 - 失败静默。注册表键名写错一个字母,浏览器表现就是「点了没反应」,控制台干干净净,这是最费时间的一类问题。
- 连点会起多个进程。营销页那种「点击下载」按钮,用户连点三下就是三个进程。
判断标准我给个表,接需求时对着填一遍,基本不会选错:
| 需求 | 协议方案 | 本地桥接 |
|---|---|---|
| 只唤起客户端,不关心结果 | 合适 | 能用但偏重 |
| 需要拿到执行结果或错误码 | 不合适 | 必须 |
| 需要传文件、二进制、大 JSON | 不合适 | 合适 |
| 需要进度条、取消操作 | 不合适 | 合适 |
| 目标机器禁止常驻进程 | 唯一选择 | 不合适 |
| 用户是纯浏览器环境、不能装东西 | 两者都不行 | 两者都不行 |
最后一行要特别说清楚:这两套方案都要求本机能跑你的程序。纯网页、零安装的场景,任何「web 调用本地应用」的说法都站不住,早点和业务方对齐,比后面返工省事。
2.2 写注册表:最小可用键值就那几个
注册表这块踩过的最大的坑是权限。写HKEY_CLASSES_ROOT需要管理员,而很多公司的办公机用户没有管理员权限,安装器一跑就失败。实际我一般写HKEY_CURRENT_USER\Software\Classes,当前用户级别,免管理员,浏览器同样认,卸载时也好清理。
先给一份可以双击导入的.reg:
Windows Registry Editor Version 5.00 [HKEY_CURRENT_USER\Software\Classes\myapp] @="MyApp Protocol" "URL Protocol"="" [HKEY_CURRENT_USER\Software\Classes\myapp\DefaultIcon] @="\"C:\\Program Files\\MyApp\\launcher.exe\",0" [HKEY_CURRENT_USER\Software\Classes\myapp\shell] [HKEY_CURRENT_USER\Software\Classes\myapp\shell\open] [HKEY_CURRENT_USER\Software\Classes\myapp\shell\open\command] @="\"C:\\Program Files\\MyApp\\launcher.exe\" \"%1\""几个键值的含义必须逐条对上,少一个都不行:
myapp这个键名就是 scheme 名,只能用字母,别带下划线、别用数字开头,否则部分浏览器直接不认。- 默认值
@="MyApp Protocol"只是显示名,弹框里给用户看的,可以随意。 "URL Protocol"=""是开关,必须是空字符串值,不是空键。缺了它,浏览器只当成一个普通文件关联,不会走协议唤起。这一条是最常见的翻车点。command的默认值里,exe 路径整体加引号,%1也加引号。少任何一个,C:\Program Files里的空格就会把路径截断,或者 URL 里的&被当成命令分隔符。
实际项目里我不用.reg,用 PowerShell 脚本,因为路径要参数化,还要塞进安装流程:
param( [string]$Scheme = "myapp", [string]$Exe = "C:\Program Files\MyApp\launcher.exe" ) $root = "HKCU:\Software\Classes\$Scheme" # 1. 建主键,Force 保证重复执行不报错(升级场景会重复跑) New-Item -Path $root -Force | Out-Null Set-Item -Path $root -Value "MyApp Protocol" # 2. URL Protocol 必须是空字符串值,这一行决定浏览器认不认 New-ItemProperty -Path $root -Name "URL Protocol" -Value "" -PropertyType String -Force | Out-Null # 3. 图标(可选),格式是 "路径",索引 New-Item -Path "$root\DefaultIcon" -Force | Out-Null Set-Item -Path "$root\DefaultIcon" -Value "`"$Exe`",0" # 4. 命令行:exe 和 %1 都要用反引号转义出双引号 New-Item -Path "$root\shell\open\command" -Force | Out-Null Set-Item -Path "$root\shell\open\command" -Value "`"$Exe`" `"%1`"" # 5. 写个版本号,前端和升级逻辑都能读 New-ItemProperty -Path $root -Name "Version" -Value "1" -PropertyType String -Force | Out-NullPowerShell 里的反引号`是转义符,`"输出的就是双引号,写注册表时这一层特别容易漏。写完立刻验证,别等打开浏览器:
# 直接走 Shell 层验证,绕过浏览器的确认框 Start-Process "myapp://open?id=123&name=%E5%BC%A0%E4%B8%89" # 查一下最终落盘的命令行长什么样 reg query "HKCU\Software\Classes\myapp\shell\open\command" /veStart-Process能起来,说明注册表和启动器这一段是通的;起不来,问题一定在注册表或路径空格上,和前端无关。这个二分法能省掉大量「到底是页面问题还是客户端问题」的扯皮。
2.3 启动器要做的三件事:取参数、去重、回调
启动器(launcher.exe)不是主程序,它只干三件事,干完就退或者转交。
第一件是取参数并解码。sys.argv[1]拿到的是完整 URL,形如myapp://open?id=123&name=%E5%BC%A0%E4%B8%89,要拆出 path 和 query,query 里的值还要 URL 解码,否则中文全是%E5%BC%A0这种。
第二件是去重。浏览器连点、页面重复触发、用户手快,都会让同一个请求进来好几次。我一般用临时目录里的一个标记文件,按业务 id 做 key,几秒内只放行一次。
第三件是回调。协议方案没有返回值,但业务往往还是要给页面一个交代。常见的三种土办法:写剪贴板让用户自己粘(体验最差)、反向用一个http://链接打开一个结果页(能用,但会开新标签)、或者干脆起一个极小的本地接口让前端来问。第三种其实就是第 3 章的思路了。
import sys, os, time, hashlib, urllib.parse LOCK_DIR = os.path.join(os.environ.get("TEMP", "."), "myapp-lock") def parse_arg(raw: str) -> dict: """把 myapp://open?id=123&name=%E5%BC%A0%E4%B8%89 拆成 dict""" if "://" not in raw: return {} _, rest = raw.split("://", 1) rest = rest.split("#", 1)[0] # 丢掉锚点 path, _, query = rest.partition("?") params = dict(urllib.parse.parse_qsl(query, keep_blank_values=True)) params["_path"] = path # 保留 path,便于多命令复用 return params def acquire_lock(key: str, ttl: int = 3) -> bool: """同一个 key 在 ttl 秒内只放行一次,挡住连点""" os.makedirs(LOCK_DIR, exist_ok=True) fp = os.path.join(LOCK_DIR, hashlib.md5(key.encode("utf-8")).hexdigest()) now = time.time() if os.path.exists(fp) and now - os.path.getmtime(fp) < ttl: return False with open(fp, "w") as f: f.write(str(now)) return True def main() -> int: raw = sys.argv[1] if len(sys.argv) > 1 else "" params = parse_arg(raw) if not params: return 2 # 没有参数,明确失败,便于排查 if not acquire_lock(params.get("id") or raw): return 0 # 重复请求,静默退出,不报错 # 真正的业务动作放这里:拉起主程序、写临时文件、调用本地接口 print(params) return 0 if __name__ == "__main__": sys.exit(main())ttl这个参数按业务调:报表打印这种慢操作给 5 秒,打开客户端给 2 秒就够。_path保留下来是为了以后能扩出myapp://print?...、myapp://open?...这种多命令,不用改解析逻辑。返回码也要讲究,0表示成功或忽略,非0表示参数错误,安装脚本和排错脚本可以拿它做判断。
3. 本地桥接服务:用 HTTP 通道把 web 和 exe 接成一条能回传结果的链路
3.1 为什么迁到本地服务:协议方案的三个硬伤
协议方案跑通之后,业务方提的三个新需求基本会把它判死刑:要显示「正在打印,第 2/5 页」、要能点取消、要传一个 8MB 的 PDF 过去。这三件事在单向唤起里一件都做不到,因为它们都需要回传。
本地桥接服务就是在127.0.0.1上起一个 HTTP 服务,由它代替浏览器执行本地动作,再把结果以 JSON 返回给页面。前端从「扔出去不管」变成「发请求等响应」,超时、重试、错误码、进度全都有了抓手。
代价也要认:多了一个常驻进程,占几十兆内存;第一次启动时 Windows 防火墙可能弹窗(绑定回环地址一般不会,但保险起见安装时就要处理);端口要管理,进程要守护,升级时还要处理旧进程残留。这些在第 5 章讲。
选型上我一般这么分:只唤起、用户点一次就完事、机器性能紧张,用协议方案;要结果、要交互、要传数据,用桥接服务;两者都要,就协议方案负责「把桥接服务拉起来」,桥接服务负责干活。这个组合在实际项目里用得最多,页面先myapp://start,再轮询http://127.0.0.1:PORT/ping,起来了就发正式请求。
3.2 起一个本地 HTTP 桥:CORS、端口与握手令牌
先给最小可运行的实现,只用 Python 标准库,不引第三方依赖,方便塞进任何安装包:
import json, os, secrets, subprocess from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # 令牌每次启动随机生成;也支持外部注入,便于安装器统一管理 TOKEN = os.environ.get("MYAPP_TOKEN") or secrets.token_urlsafe(16) # 只允许这些来源跨域访问,别写 "*" ALLOW_ORIGIN = { "http://localhost:5173", "https://your-intranet.example", } # 白名单命令表:前端只能传 action 名,永远不能传路径 SAFE_CMDS = { "open_report": [r"C:\Program Files\MyApp\report.exe", "--mode=view"], "print_label": [r"C:\Program Files\MyApp\label.exe"], } class Handler(BaseHTTPRequestHandler): server_version = "MyAppBridge/1" protocol_version = "HTTP/1.1" def _cors(self): origin = self.headers.get("Origin", "") if origin in ALLOW_ORIGIN: self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Vary", "Origin") self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Token") self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") self.send_header("Access-Control-Max-Age", "600") def do_OPTIONS(self): # 预检请求必须单独处理,否则前端只会看到一个 405 self.send_response(204) self._cors() self.send_header("Content-Length", "0") self.end_headers() def do_GET(self): if self.path == "/ping": body = json.dumps({"ok": True, "version": "1"}).encode() self.send_response(200) self._cors() self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return self.send_error(404) def do_POST(self): if self.path != "/exec": self.send_error(404); return if self.headers.get("X-Token") != TOKEN: self.send_error(401); return length = int(self.headers.get("Content-Length", 0)) if length > 64 * 1024: # 限体,挡住误传大文件 self.send_error(413); return body = json.loads(self.rfile.read(length) or b"{}") cmd = SAFE_CMDS.get(body.get("action")) if not cmd: self.send_error(400); return try: proc = subprocess.run(cmd, capture_output=True, timeout=20) payload = json.dumps({ "code": proc.returncode, "stdout": proc.stdout.decode("utf-8", "replace")[:4096], }).encode() except subprocess.TimeoutExpired: payload = json.dumps({"code": -1, "stdout": "timeout"}).encode() self.send_response(200) self._cors() self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) if __name__ == "__main__": srv = ThreadingHTTPServer(("127.0.0.1", 17654), Handler) print("bridge on 17654, token:", TOKEN) srv.serve_forever()这段代码里有四个决定成败的参数,逐个说:
绑定地址必须是127.0.0.1,不能是0.0.0.0。写成后者,同一局域网里任何人都能调你的本地命令,这是安全事故级别的错误。只绑回环,外面的机器连不上,防火墙一般也不拦。
端口固定还是动态。固定端口(上面用的 17654)简单,前端硬编码就行;缺点是可能被别的程序占。动态端口更干净,但前端没法知道端口号,得靠协议方案带参、或者写一个port.txt让页面读(页面读不到文件),实际很难落地。我用固定端口,高位、避开常见服务,再加一层启动前探活。
令牌X-Token的作用要说清楚:它挡的是「别的网页误调你的本地接口」,不是身份认证。任何能读到这个页面的人都能拿到 token。所以别把它当权限系统,真正的权限校验必须在 exe 里再走一遍。
白名单命令表SAFE_CMDS。前端只能传action名,路径永远在服务端拼。只要允许前端传路径,这个接口就等于给了一个任意程序执行入口,没有任何商量余地。
CORS 那几行也别省。前端发Content-Type: application/json会触发预检,浏览器先发一个OPTIONS。只实现了POST没实现OPTIONS,页面上的表现就是一个语焉不详的跨域报错,Network 面板里能看到OPTIONS返回 405——这个坑太经典了,第 4 章单独说。
3.3 前端封装:超时、重试和心跳怎么写
桥接服务有个天然的不确定:它可能没起来。用户刚开机、进程刚崩、安装没完成,页面都得给出人话提示,而不是转圈转到天荒地老。所以前端封装的第一件事是探活,第二件事是超时,第三件事才是发请求。
const BASE = "http://127.0.0.1:17654"; let token = null; // 探活:800ms 内没回应就当服务没起,直接走引导 async function ping(timeout = 800) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { const r = await fetch(`${BASE}/ping`, { signal: ctrl.signal }); return r.ok; } catch { return false; } finally { clearTimeout(timer); } } async function callExec(action, payload = {}, retry = 1) { for (let i = 0; i <= retry; i++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 20000); // 要大于服务端 20s 超时 try { const r = await fetch(`${BASE}/exec`, { method: "POST", headers: { "Content-Type": "application/json", "X-Token": token }, body: JSON.stringify({ action, ...payload }), signal: ctrl.signal, }); if (r.status === 401) throw new Error("TOKEN_INVALID"); // 不重试 if (!r.ok) throw new Error(`HTTP_${r.status}`); return await r.json(); } catch (e) { if (e.message === "TOKEN_INVALID") throw e; if (i === retry) throw e; await new Promise(res => setTimeout(res, 300)); // 退避后再试 } finally { clearTimeout(timer); } } }几个细节值得单独拎出来。
前端超时必须比服务端超时长。上面服务端subprocess.run(timeout=20),前端给 20000 毫秒是不够的,网络加上序列化会顶到边界,实际项目里前端给 25 秒。反过来写,前端先断,服务端还在跑,用户看到报错但其实打印已经发出去了,这种不一致最难解释。
401 不重试。token 错了重试一百次也是错,只会把日志刷满。重试只对网络层抖动有意义,我一般只重试一次,间隔 300 毫秒。
token 怎么给到前端。两条常见路径:一是协议方案拉起桥接服务时,把 token 拼在打开的页面上(https://your-intranet.example/work?t=xxx),页面从 query 里读;二是安装时把 token 写进前端的一份内网配置里。前者灵活,后者简单,看你们的部署方式。都别在前端代码里硬编码一个固定 token,那等于没有。
心跳。长驻页面建议每 30 秒 ping 一次,连续两次失败就把「本地服务未连接」的横幅挂出来,同时给出下载/启动引导。别等用户点了按钮才发现服务掉了。
4. 避坑与排查:协议不弹窗、预检 405、端口占用这五类故障
4.1 启动链路:点了没反应,或者弹了框程序没起
第一条,现象:页面里点链接完全没反应,控制台无报错,Network 里也没有请求。原因:注册表根本没写进去,或者URL Protocol这个值漏了。我见过最隐蔽的一次是键名写成了my_app,下划线在某些浏览器里不被识别为合法 scheme,表现就是彻底静默。排查顺序:先reg query "HKCU\Software\Classes\myapp"看键在不在;再确认URL Protocol是空字符串值而不是空键;最后用Start-Process "myapp://open?id=1"绕过浏览器直接测 Shell 层。Shell 层能起,问题就在浏览器策略(企业环境可能禁了外部协议跳转),Shell 层也起不来,问题就在注册表或 exe 路径。
第二条,现象:浏览器弹了「是否打开」,用户点了打开,程序没起来。原因:command里的引号没加对。两种典型错法:exe 路径没整体加引号,C:\Program Files\MyApp\launcher.exe被空格截成C:\Program;或者%1没加引号,URL 里的&name=xxx被 cmd 当成命令分隔符,后半截丢了。解决办法就是把两处都加引号,然后用reg query ... /ve把最终值打出来肉眼核对一遍——很多时候你以为写的是"C:\...\launcher.exe" "%1",实际落盘的是C:\...\launcher.exe "%1"。
第三条,现象:中文参数变成乱码,或者 URL 里带#之后的内容全丢了。原因有两个层面:#是锚点分隔符,浏览器在交给系统之前就把它后面的内容截掉了,所以业务参数里绝不能出现裸#,必须先encodeURIComponent;中文乱码则是解码侧没做unquote,parse_qsl默认会按 UTF-8 解,但如果中间过了一层.bat批处理,代码页一切换就全乱了。我的做法是启动器直接是 exe,不经过 bat 中转,参数一律encodeURIComponent后再拼。
4.2 通道与参数:跨域报错、端口占用、令牌失效
第四条,现象:前端 fetch 报跨域,Network 面板里OPTIONS请求返回 405 或 501,POST压根没发出去。原因:本地服务只实现了POST,没实现OPTIONS预检。只要请求带了Content-Type: application/json或自定义头X-Token,浏览器就一定先发预检。解决就是在服务里补一个do_OPTIONS,返回 204,并把Access-Control-Allow-Origin、Allow-Headers、Allow-Methods三个头带全。这里还有个次级坑:Allow-Origin千万别图省事写*,一旦以后要带 cookie 就全废,而且写*等于允许任何网页调你的本地接口。
第五条,现象:本地服务时好时坏,刷新一下又通了,重启电脑必好。原因:端口被上一个没退干净的进程占着,新进程 bind 失败直接退出,页面上表现就是「连不上」。桥接服务崩了之后进程不一定马上释放端口,尤其是被任务管理器强杀的情况。解决办法分两层:服务启动时先探测端口,占用就尝试连一下/ping,如果是自己的旧实例就复用或先杀掉;同时用命名互斥量保证单实例,避免用户双击图标起出三个。前端那侧也要能区分「服务没起」和「服务起了但命令失败」,前者给启动引导,后者给错误码,别都笼统报「操作失败」。
5. 打包与签名:把启动器和桥接服务做成能装能升的安装包
5.1 进程守护与开机自启:别做成 Windows 服务
桥接服务要常驻,第一个念头往往是做成 Windows 服务。这个方向对大多数场景是错的:服务跑在 Session 0,和用户桌面是隔离的,你的 exe 一旦需要弹窗、调用打印机驱动、访问用户目录下的文件,就会各种诡异失败,而且失败得毫无提示。我在这上面浪费过整整两天,日志里一切正常,用户那边什么都没发生。
正解是跟着用户会话走:
- 登录自启:写
HKCU\Software\Microsoft\Windows\CurrentVersion\Run,值是启动器路径。简单、免管理员,缺点是用户可以在任务管理器里随手关掉。 - 计划任务:触发器选「登录时」,勾上「如果任务已在运行则不启动新实例」。比 Run 键可靠一点,还能配延迟启动,避开开机那一波资源争抢。
- 托盘常驻:启动器起一个托盘图标,服务挂了自动重启,用户也能手动退出。这是体验最好的一种,代价是要多写一点 UI 代码。
守护逻辑我一般这么写:主进程里起一个线程,每 10 秒 ping 一次自己的桥接端口,连续三次失败就重新拉起子进程,同时记一行日志。重启要有上限,比如 5 分钟内最多 3 次,否则服务本身有 bug 时会陷入无限重启,把 CPU 吃满。
升级是另一个容易忽略的点。安装包覆盖安装前必须先停掉旧进程,否则文件被占用,安装器报「无法写入」。我一般让安装脚本先发一个POST /shutdown(带 token),等 2 秒,再taskkill /F兜底,然后才写文件。这个顺序写反了,用户会遇到「装完了但还是旧版本」的玄学问题。
5.2 注册表写入、卸载清理与签名这摊事
安装包里要干的活其实就四件:铺文件、写协议注册表、配自启、打版本号。卸载则要严格对称地清干净,尤其是注册表——留一个残键,用户重装到别的路径时就会指向一个不存在的 exe,表现是「点了没反应」,而且极难自查。
# 卸载:先停进程,再删注册表,最后删文件(顺序不能反) param([string]$Scheme = "myapp") Get-Process launcher -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 500 Remove-Item -Path "HKCU:\Software\Classes\$Scheme" -Recurse -Force -ErrorAction SilentlyContinue Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` -Name "MyAppLauncher" -ErrorAction SilentlyContinue-ErrorAction SilentlyContinue是必要的,卸载脚本要能容忍「本来就没装全」的状态,不能因为一个键不存在就中断整个卸载流程。
版本号建议同时写两处:注册表里的Version值(前端可以间接读到,也方便运维排查),以及 exe 的文件版本信息(Get-Item launcher.exe | Select VersionInfo)。两边对不上,基本就是升级没成功。
最后是签名。未签名的 exe 首次运行会触发 SmartScreen 的蓝色提示,「Windows 已保护你的电脑」,用户得点「更多信息 → 仍要运行」。这个提示对内部系统的转化率影响很大,很多「点了没反应」的工单其实是用户看到蓝屏提示直接关掉了。有条件就买代码签名证书,内网环境可以用企业自建证书 + 组策略下发信任,成本低很多。签名之后还有个附带好处:安装包的完整性校验能过,升级流程会顺很多。
6. 从能跑到敢用:三层校验清单与一份自检脚本
这套东西上线前,我会强制走一遍三层校验,每层都有明确的判据,不靠「我这儿是好的」。
| 层 | 检查项 | 通过判据 |
|---|---|---|
| 系统层 | 协议键存在、URL Protocol为空串、command 引号正确 | Start-Process "myapp://..."能起进程 |
| 进程层 | 桥接服务单实例、端口监听、崩溃能自愈 | 连续杀三次进程,10 秒内自动恢复 |
| 通道层 | 预检 204、token 校验、白名单命令、超时一致 | 前端超时 > 服务端超时,401 不重试 |
把这三层写成一个脚本,交付时和安装包一起给运维:
# 三层自检:注册表 -> 进程 -> 通道 $scheme = "myapp" $port = 17654 Write-Host "[1/3] 协议注册" reg query "HKCU\Software\Classes\$scheme\shell\open\command" /ve Write-Host "[2/3] 进程与端口" Get-Process launcher -ErrorAction SilentlyContinue | Select-Object Id, Path Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort Write-Host "[3/3] 通道" try { $r = Invoke-WebRequest "http://127.0.0.1:$port/ping" -TimeoutSec 2 "ping: $($r.StatusCode)" # 预检是否被正确处理,看返回码是不是 204 $o = Invoke-WebRequest "http://127.0.0.1:$port/exec" -Method Options ` -Headers @{ Origin = "https://your-intranet.example" } -TimeoutSec 2 "options: $($o.StatusCode)" } catch { "bridge down: $($_.Exception.Message)" }Get-NetTCPConnection那一行是重点,很多人只看进程在不在,不看端口——进程活着但 bind 失败的情况完全可能存在,表现为「服务在跑但连不上」,光看进程列表会误判。
最后说个我自己的习惯。这套链路里最贵的不是代码,是排查成本:用户说「点了没反应」,你没法远程看他的注册表、看他的端口、看他的浏览器策略。所以从那以后我每次交付这类项目,都会强制把上面这份自检脚本塞进安装目录,再在启动器里加一个「诊断」入口,一键把注册表值、端口状态、最近 200 行日志打包成一个 txt 放到桌面。工单量能降一大截,因为一半的问题用户自己就把答案贴过来了。希望帮到你。
本文还有配套的精品资源,点击获取