ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用自定义URL Scheme实现Web页面唤起本地程序(EXE/脚本)

用自定义URL Scheme实现Web页面唤起本地程序(EXE/脚本) 简介面向Web开发者与桌面应用集成场景的实用示例资料围绕页面如何安全调用本地程序展开。内容结合描述中所列的ActiveX、自定义URL协议、WebSocket桥接等方式整理出一套可在Windows下实际运行与验证的演示环境。资源共4个文件压缩包仅189KB核心为reg注册表脚本用于注册自定义协议入口配套HTML演示页可触发外部调用exe为本地接收端程序txt则对配置步骤和使用注意事项作简要说明适合用于快速理解调用链路与协议注册原理。已有453人学习下载。借助该资料读者能直观看到从浏览器链接到本地进程响应的完整流程既可作为课程设计或技术分享的参考也可为后续使用Electron或WebView2等方案提供对比基础。所有文件均为轻量级独立组成便于直接查看改动效果适合有一定前端基础、希望探索混合应用开发的用户。1. 一个老需求网页按钮点开本地软件在web项目里放一个按钮点击后唤起本地 exe、批处理或脚本干活——这大概是企业级 web 开发里被问得最频繁的需求。MES 看板要调扫码枪、OA 要调本地打印、巡检系统要拉起运维工具浏览器出于安全沙箱从不允许网页直接执行本地程序于是“web 调用本地应用程序”就成了一个绕不开的命题。实践中最稳的方案是给 Windows 注册一个自定义 URL Scheme让浏览器像打开mailto:一样拉起本地协议处理器。这套方案不需要浏览器插件、不需要客户端常驻用一份 zip 把注册脚本、协议处理程序和示例网页装好就能交付。本文就把这条路径完整拆开协议怎么注册、参数怎么传、返回值怎么拿、坑在哪。2. 注册自定义协议让浏览器认识 myapp://2.1 为什么用自定义 URL Scheme协议注册的底层逻辑浏览器不能执行 exe但浏览器能打开链接。mailto:、tel:这类链接会被系统路由到对应的本地程序Windows 在注册表里维护着一张“协议名 → 处理程序”的映射表。我们做的事情本质上就是照葫芦画瓢注册一个myapp://协议指向我们的本地程序浏览器一遇到这个协议就把它交给系统系统再拉起程序并把协议后的内容作为参数传进去。与其用http://localhost:xxxx轮询本地端口或写 ActiveX 插件自定义 URL Scheme 的最大优势在权限模型干净浏览器不需要任何额外许可用户第一次点按时确认一次“打开此应用程序”之后就能持续调用。交付形态也直白——一个 zip解压后双击install.bat即可完成注册符合运维的预期。2.2 写注册表最小可用的 myapp 协议先做最小闭环。在 Windows 上注册一个协议本质是在注册表两个位置写入键值HKEY_CLASSES_ROOT\myapp描述协议本身HKEY_CLASSES_ROOT\myapp\shell\open\command描述要拉起的程序。为了方便测试先让协议指向 cmd 的一个 echo 行为Windows Registry Editor Version 5.00 [HKEY_CLASSES_ROOT\myapp] URL:MyApp Protocol URL Protocol [HKEY_CLASSES_ROOT\myapp\shell] [HKEY_CLASSES_ROOT\myapp\shell\open] [HKEY_CLASSES_ROOT\myapp\shell\open\command] \C:\\myapp\\gateway.bat\ \%1\这段注册表保存为myapp.reg双击导入然后在浏览器地址栏输入myapp://hello系统会弹出“打开 MyApp Protocol”的确认框。这段内容的逻辑是URL Protocol空值告诉 Windows 这是一个 URL 协议%1是浏览器传入的完整链接字符串gateway.bat里的%1就是myapp://hello。需要注意command键的路径中\\是注册表文件里的转义写法实际值里是一个反斜杠路径不要用环境变量协议处理程序对%PATH%的解析并不稳定。如果gateway.bat传参会丢引号最稳的做法是在批处理里用%~1去掉外层引号。2.3 安装脚本与卸载脚本zip 交付的完整形态由于.reg双击导入会弹 UAC 且用户容易误操作实际落地时不建议把.reg直接交给客户。我会把注册脚本做进install.bat里用reg add命令逐项写入并附带一个uninstall.bat做反注册。这样 zip 交付包内只需要四个文件安装脚本、卸载脚本、协议处理程序、示例页面。echo off setlocal set APP_NAMEmyapp set GATEWAY_PATHC:\myapp\gateway.bat rem 注册协议主键 reg add HKCR\%APP_NAME% /ve /d URL:MyApp Protocol /f reg add HKCR\%APP_NAME% /v URL Protocol /t REG_SZ /d /f rem 注册打开动作与处理程序 reg add HKCR\%APP_NAME%\shell\open\command /ve /d \%GATEWAY_PATH%\ \%%1\ /f echo [OK] MyApp protocol registered. echo 打开浏览器访问: myapp://hello endlocal这里的关键是reg add命令里%%1的转义写法——在批处理中单个%需要用%%表示否则会被当成批处理变量展开。/ve表示写入默认值/f表示覆盖不确认。卸载脚本则是把HKCR\myapp整个键删除。提示C 盘根目录建C:\myapp需要管理员权限安装脚本要以管理员身份运行。若部署环境不允许写 C 盘根目录把网关路径换成%~dp0gateway.bat即 zip 解压后的当前目录安装脚本放到哪个目录协议就指向哪个目录。3. 网页侧调用从 href 到 iframe 的参数传递3.1 最简单的触发方式与用户手势限制协议注册完成后网页侧调用就是写一个链接的事a hrefmyapp://hello启动本地程序/a但实际开发中直接用a标签有几个问题。一是 Chrome 和 Edge 对协议跳转的白屏行为有差异点击后标签页会停留在当前页面等待系统响应用户不知道发生了什么。二是大多数场景需要拿到调用结果纯链接方式无法感知本地程序是否启动成功。所以我一般不用a而是在click事件里用window.location.href触发并同步给出 UI 反馈。function launchLocalApp(payload) { const scheme myapp://launch?data encodeURIComponent(JSON.stringify(payload)); // 记录发起时间用于后续超时判断 window.__launchTimer Date.now(); window.location.href scheme; // 500ms 后如果没有收到本地回执提示用户检查协议是否注册 setTimeout(() checkReceipt(payload.requestId), 500); }这段代码的encodeURIComponent是必须的。本地程序拿到的%1是浏览器传入的原始 URL 字符串如果 payload 里含有、、?、中文不编码的话协议命令行的参数分隔会错乱。requestId是每次调用的唯一标识用于后续接收返回值时对账。3.2 带参数调用URL 编码与 JSON 序列化当业务参数复杂到需要传一组结构化数据时常见的做法是把 JSON 序列化后整体放进自定义协议。但协议路径里出现{}和会被命令行解析干扰所以必须做两层处理// 第一层JSON 序列化 const payload { requestId: REQ-20250101-001, action: print, filePath: \\\\server\\shared\\report.pdf, copies: 2, printer: HP LaserJet MFP, options: { duplex: true, color: mono } }; // 第二层URL 编码确保命令参数不被切分 const data encodeURIComponent(JSON.stringify(payload)); const url myapp://do?data${data}; // 使用隐藏 iframe 触发避免页面导航闪现 const iframe document.createElement(iframe); iframe.style.display none; iframe.src url; document.body.appendChild(iframe);用 iframe 触发是网页前端开发里比较成熟的做法比window.location.href的好处是浏览器地址栏不变页面不会闪现“正在跳转到外部协议”的中间态。注意 iframe 触发后要setTimeout移除节点否则会累积无用节点。参数长度是这个方案的软肋。自定义协议不是GET请求命令行有长度上限Windows 命令行整体长度约 8191 字符编码后的大 JSON 很容易撞限。超过这个规模不要试图把业务数据全部塞进协议 URL而是让本地程序自己去读一个临时文件——网页先把 JSON 写到一个共享目录协议 URL 只传文件路径和 requestId。3.3 HTTP 页面与 HTTPS 页面的拦截差异部署环境不同协议调用的体验会差很多。HTTP 页面下Chrome 对myapp://的确认框比较宽松用户点“打开”后不会再重复询问。但HTTPS 页面下 Chrome 的策略会收紧如果本地程序没有任何回写机制页面会一直显示“正在打开 MyApp Protocol...”约 20 秒后提示“未收到响应”。这其实是浏览器在等待协议处理器返回很多新手会误以为是协议注册失败。Firefox 则不区分 HTTP/HTTPS每次都问“启动应用程序”。这一差异直接决定要不要在网页里做协议是否注册的预检测。常见的预检测手法是用一个隐藏 iframe 指向myapp://ping配合window.blur事件判断——如果页面失去焦点说明系统弹出了确认框或已拉起本地程序协议大概率已注册如果blur没触发基本可以判定协议未注册页面直接弹错误提示。function isProtocolRegistered() { return new Promise((resolve) { let triggered false; const onBlur () { triggered true; cleanup(); resolve(true); }; // 主流浏览器在拉起外部协议时页面会短暂失焦 window.addEventListener(blur, onBlur); const cleanup () window.removeEventListener(blur, onBlur); // 500ms 内未失焦认为协议未注册 setTimeout(() { if (!triggered) { cleanup(); resolve(false); } }, 500); const iframe document.createElement(iframe); iframe.style.display none; iframe.src myapp://ping; document.body.appendChild(iframe); }); }这个检测的误报率存在用户电脑上如果装了安全软件拦截协议拉起页面也不会失焦但作为第一道防线足够。真正可靠的确认还是要靠本地程序回写。4. 本地处理端解析参数、干活、回写状态4.1 用 Python 做协议处理器解析 %1 的标准姿势批处理做协议处理器调试方便但一旦要解析 JSON、调打印机、写日志批处理就力不从心了。我通常的做法是注册表指向一个gateway.bat批处理再把参数转发给同目录下的gateway.py。这样 Python 环境有变动时改批处理即可。echo off setlocal set GATEWAY%~dp0gateway.py set RAW%~1 python %GATEWAY% %RAW% endlocal%~dp0是批处理所在目录%~1是去掉外层引号的原始协议串。Python 侧的工作是解析这条协议串、分发到不同 handler、执行并回写import sys import json import urllib.parse import logging def parse_protocol(raw_url: str): # raw_url 形如 myapp://do?data%7B%22requestId%22%3A...%7D prefix myapp:// if not raw_url.startswith(prefix): raise ValueError(funknown protocol: {raw_url}) body raw_url[len(prefix):] # 拆出 action 与 query path, _, query body.partition(?) params urllib.parse.parse_qs(query) # data 参数是 URL 编码后的 JSON 串 if data in params: data json.loads(urllib.parse.unquote(params[data][0])) else: data {} return {action: path, data: data} def main(): logging.basicConfig( filenamerC:\myapp\gateway.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s ) raw_url sys.argv[1] if len(sys.argv) 1 else logging.info(receive: %s, raw_url) try: req parse_protocol(raw_url) action req[action] if action ping: # 探测协议是否注册只需返回文本 print(pong) elif action do: handle_business_action(req[data]) else: logging.warning(unknown action: %s, action) except Exception as exc: logging.error(handle error: %s, exc, exc_infoTrue) sys.exit(1) if __name__ __main__: main()parse_qs会自动处理 URL 解码但会被解析成空格如果业务数据里含号比如 Base64 串需要传入keep_blank_valuesTrue并在解码后做一次replace( , )兜底。data 参数之外的业务字段不要扩展所有业务数据收进 JSON 里协议层只负责传输。4.2 返回值的两种做法本地 HTTP 回调与 WebSocket 广播浏览器无法直接读取协议处理程序的 stdout所以返回值必须另走通道。最常用、最好维护的是本地 HTTP 回调Python 网关在启动时起一个http.server监听随机端口网页用fetch轮询这个端口的/result接口拿结果。import json import threading import socket from http.server import BaseHTTPRequestHandler, HTTPServer class ResultHandler(BaseHTTPRequestHandler): result_store {} def do_GET(self): if self.path.startswith(/result/): request_id self.path.split(/)[-1] result self.result_store.get(request_id, {}) # 允许跨域方便 http/https 页面访问 self.send_response(200) self.send_header(Content-Type, application/json; charsetutf-8) self.send_header(Access-Control-Allow-Origin, *) self.end_headers() self.wfile.write(json.dumps(result).encode(utf-8)) def log_message(self, *args): pass # 关闭默认请求日志避免刷屏 def start_result_server(): for port in range(38080, 38100): try: httpd HTTPServer((127.0.0.1, port), ResultHandler) thread threading.Thread(targethttpd.serve_forever, daemonTrue) thread.start() return port except OSError: continue raise RuntimeError(no free port for result server)这里端口范围38080~38100是精心选的避开 8080 这类常见开发端口减少被占用概率监听地址固定127.0.0.1防止局域网其他机器访问到结果接口。CORS 头必须加因为网页可能是 HTTP 或 HTTPS 页面而回调接口是http://127.0.0.1两者不在同一源否则fetch会被浏览器拦截。网页侧轮询的写法是调用协议时生成requestId然后每 300ms 轮询一次结果接口3 秒超时async function waitResult(requestId) { const port window.__resultPort; const url http://127.0.0.1:${port}/result/${requestId}; for (let i 0; i 10; i) { const resp await fetch(url); const data await resp.json(); if (data data.status data.status ! PENDING) { return data; } await sleep(300); } throw new Error(timeout: local gateway no response); }port的传递是一个隐藏问题。网页不知道网关监听了哪个端口常见做法是网关把端口号写入一个固定路径的文本文件如C:\myapp\port.txt网页在页面初始化时读取一次并缓存。为什么不用固定端口因为企业环境里端口被占用的概率出奇地高固定端口惹来的换端口操作远比写个文件读取麻烦。4.3 单实例锁与防重复唤起协议处理器被拉起时如果一个实例还在跑第二个实例又启动了会导致同一请求被处理两次。系统里跑tasklist查重不优雅我用一个文件锁解决网关启动时尝试以独占方式打开C:\myapp\gateway.lock打不开说明已有实例在运行把协议参数转交给已运行的实例后退出。import os import socket import struct LOCK_FILE rC:\myapp\gateway.lock def acquire_single_instance(): try: # 独占方式打开锁文件第二个实例会抛异常 fd os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.write(fd, str(os.getpid()).encode()) return fd except FileExistsError: return None def handoff_to_running_instance(raw_url: str): # 通过本地 socket 把参数转给主实例 sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((127.0.0.1, 38099)) sock.sendall(raw_url.encode(utf-8)) sock.close()锁文件的问题在于进程崩溃时会残留。用O_EXCL创建锁文件后如果进程被强杀锁文件不会自动删除。解决方式是写两个判断锁文件里存 PID启动时读 PID 然后用os.kill(pid, 0)探测该进程是否还活着死了就把锁文件删掉重新创建。注意os.kill(pid, 0)在 Windows 上不是幂等的对已经结束的进程会抛ProcessLookupError对 PID 被复用的进程会误判存活。更稳的做法是配合psutil.Process(pid).name()检查进程名是不是python.exe双条件确认。5. 避坑web 调用本地程序最常见的 5 个翻车现场5.1 现象Chrome 提示“您想打开此应用程序吗”但点了没反应点“打开”后系统没有拉起任何程序也没有报错。最常见的原因是注册表command键里的程序路径带了引号但批处理没处理干净。比如注册表写成C:\myapp\gateway.bat %1浏览器传给批处理的是myapp://do?data...完整串但批处理里%~1只剥离了最外层引号如果链接本身含符号cmd会把当命令连接符执行表现为“闪一下就没了”。排查时先在cmd里手动执行注册表里的命令C:\myapp\gateway.bat myapp://do?data%7B%22test%22%3A%22hello%22%7D如果手动执行没问题浏览器侧有问题那大概率是 Chrome 的安全策略拦了协议拉起去chrome://settings/handlers里检查默认协议处理器。如果手动执行就报错那必然是参数解析的锅——重点检查和引号。5.2 现象参数到本地少了一截JSON 被截断网页传myapp://do?data{action:print,file:C:\\ab.pdf}本地拿到的只有myapp://do?data{action:print,file:C:\\a。这是cmd在解析参数时把当成命令分隔符把链接截断了。解决思路是两层第一层业务数据里出现的所有特殊字符都交给encodeURIComponent处理这是网页侧能做的全部第二层注册表的command命令里用引号把%1包住批处理再用%*接收所有参数后拼接。%*能拿到完整参数但引号处理更麻烦我一般用set RAW%*然后再处理首尾引号。echo off set RAW%* set RAW%RAW:~1,-1% python %~dp0gateway.py %RAW%set RAW%*会带上两侧引号%RAW:~1,-1%剥离首尾各一个字符也就是去掉了最外层引号。中间的引号全部保留Python 侧再做一次urllib.parse.unquote。5.3 现象反斜杠路径到本地后少了字符网页 JSON 里写filePath:C:\Users\admin\report.pdf本地解析出来变成了C:Usersadminreport.pdf。这其实不是协议层的问题是 JSON 序列化时反斜杠没有转义。JSON.stringify({filePath: C:\\Users\\admin\\report.pdf})在JS字符串里想要输出一个反斜杠必须写成两个反斜杠\\。JavaScript 字符串字面量C:\Users\admin里\U是非法转义\a会被当成普通字符直接吞掉反斜杠。所以网页侧构造 payload 时务必用//或String.raw处理路径再进入JSON.stringifyconst filePath String.rawC:\Users\admin\report.pdf; const payload { requestId: REQ-1, filePath }; const url myapp://do?data encodeURIComponent(JSON.stringify(payload));String.raw是原样字符串模板不处理转义符Windows 路径写进去是什么样JSON 里就是什么样。5.4 现象杀毒软件弹窗拦截注册表写入企业环境里 360、火绒等安全软件对reg add HKCR和bat写注册表的敏感度很高用户点“允许”后又勾了“始终允许”结果command键指向的脚本没有写成功界面协议能用但拉起时白屏。这不是代码能完全规避的只能降低触发概率。常见做法是不注册HKEY_CLASSES_ROOT顶级键而是注册到用户级HKEY_CURRENT_USER\Software\Classes\myapp下这样不需要管理员权限Windows 的协议解析会先查用户级再查系统级。reg add HKCU\Software\Classes\myapp /ve /d URL:MyApp Protocol /f reg add HKCU\Software\Classes\myapp /v URL Protocol /t REG_SZ /d /f reg add HKCU\Software\Classes\myapp\shell\open\command /ve /d \%GATEWAY_PATH%\ \%%1\ /f用户级注册的副作用是只对当前 Windows 用户生效域环境里换用户登录就要重新注册。如果 IT 统一管控把install.bat加进域策略启动脚本是更彻底的做法。5.5 现象网关闪退浏览器提示协议处理器未响应这在开发机上是家常便饭。协议处理程序不像普通脚本有控制台窗口异常信息直接被吞掉系统只弹一个“Windows 找不到应用程序”或浏览器报“协议处理器未响应”。gateway.py里logging.basicConfig(filename...)是必须的但批处理阶段出的问题日志根本到不了 Python。为了排查我习惯把网关做成两步gateway.bat先把完整参数原样写进receive.log再调 PythonPython 侧捕获所有异常后把 traceback 写进error.log。这样从头到尾每一步都有迹可循。6. 进阶从“能调通”到“能落地”——把本地调用包装成打印专用通道协议调通了真正进入生产环境还要面对业务侧的具体场景。这里以高频的web 页面 PDF 打印为例讲一个完整闭环网页点“打印”本地网关静默调起 PDF 阅读器的打印命令并把打印结果回写页面。本地端用 Python 的subprocess调AcroRd32.exeAdobe Reader的/t静默打印参数import subprocess def silent_print(pdf_path: str, printer_name: str): # /t 参数表示后台打印不弹出打印设置界面 acro_path rC:\Program Files (x86)\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe cmd [acro_path, /t, pdf_path, printer_name] # 隐藏进程窗口避免任务栏闪现 startupinfo subprocess.STARTUPINFO() startupinfo.dwFlags | subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow 0 result subprocess.run( cmd, capture_outputTrue, timeout60, startupinfostartupinfo ) return result.returncode 0这套静默打印的坑在于 Adobe Reader 的/t参数要求 PDF 已存在本地磁盘。网页传的 PDF 如果是 blob 或远程 URL网关必须先下载或保存到临时目录再送打印。通用的做法是网页读取 PDF 的 ArrayBuffer通过协议 URL 携带文件内容由网关落盘后再打印。但协议 URL 长度有限制PDF 稍大超过 4KB就传不动。更好的思路是网关起一个上传接口网页用fetch把 PDF 二进制 POST 到http://127.0.0.1:port/upload协议 URL 只传文件 ID 和打印参数。验收一个 web 调用本地程序的交付我最后会跑一遍最简单的自检脚本确认四个环节全绿myapp://ping拉起网关、本地日志有记录、结果接口能查到 PONG、页面状态码不再转圈。协议拉起的瞬间window.blur会触发我给自己留一个习惯每次改完注册表后先手动在cmd里执行一次完整 URL 而不经过浏览器再开浏览器验证。这套玄学式的检查顺序帮我省下了多少次“为什么本地能通网页不行”的排查时间。一个方案从能跑到可靠差距往往不在主路径而在异常路径——把这些异常路径一条条收干净本地调用才能真正扛住企业环境的日常使用。希望帮到你。本文还有配套的精品资源点击获取
返回列表