浏览器唤起桌面端应用(终极目标)

46 阅读10分钟

[!WARNING] 本方案实施失败。

本文记录的是一次插件化 Launcher 的失败尝试,以及希望继续实现的终极目标。

下文代码用于还原当时的设计与问题,注意这不是已经验证成功的可运行教程。

————一次没有完成的插件化启动架构

终极篇中,我们最终使用下面的方案完成了实际落地:

浏览器 → launcher.bat → 解析参数 → 读取 cfg → 特殊处理 → 启动 App

这个方案能够工作,但少量应用差异仍然写在 launcher.bat 中。

最初设想的终极架构更进一步:Launcher 不再处理具体应用逻辑,而是把解析后的参数交给应用插件。

浏览器
  |
  | launcher://open?app=<AppKey>&param1=***&param2=***
  v
launcher.bat
  |
  | 解析 URL 参数
  | 根据 AppKey 读取 cfg
  v
plugin.bat / plugin.ps1
  |
  | 接收 Launcher 已解析的参数
  | 执行应用自己的前处理
  v
launcher.bat 等待插件执行结束
  |
  v
启动 App

职责划分也很清楚:

  • launcher.bat:解析参数、读取配置、调度插件、启动 App。
  • cfg:保存 exe、插件路径和本机配置。
  • 插件:处理不同应用的参数、环境和启动前准备。
  • App:只在插件执行完成后启动。

但真正实现时,流程卡在了最关键的一步:

Launcher 解析出的参数,在调用插件脚本时发生丢失,插件始终无法可靠取得参数。

后续即使把 Launcher 从 BAT 改成 C# 并打包为 EXE,只要它仍然需要调用 BAT 插件,这个问题就没有真正消失。


与终极篇共用的内容

协议注册、普通配置、浏览器页面和卸载逻辑没有变化,直接参考:

本文只保留插件化尝试中发生变化的完整脱敏代码。

最初设想的目录结构

Launcher/
├── launcher.bat
├── launcher.cs
├── launcher.exe
├── config/
│   ├── _template.cfg
│   ├── wps.cfg
│   └── vscode.cfg
└── plugins/
    ├── sample.bat
    └── sample.ps1

其中 plugin.ps1 只是架构中预留的插件形式。当时真正沿着失败链路继续验证的是 BAT 插件。

第一次尝试:BAT Launcher 调用 BAT 插件

配置文件

配置文件除了 exe,还声明插件类型和路径:

; config\_template.cfg

; 目标应用
exe=C:\Path\To\Application.exe

; 插件类型:bat 或 ps1
plugin.type=bat

; 插件完整路径
plugin.path=D:\Tools\Launcher\plugins\sample.bat

; 可选资源
resource=C:\Path\To\Resource

; 参数对应的环境变量名
env.resource=APP_RESOURCE_DIR
env.param1=APP_PARAM_1
env.param2=APP_PARAM_2

一个需要前处理的应用可以这样配置:

; config\vscode.cfg
exe=C:\Program Files\Microsoft VS Code\Code.exe
plugin.type=bat
plugin.path=D:\Tools\Launcher\plugins\vscode.bat
env.param1=APP_WORKSPACE

浏览器调用:

launcher://open?app=vscode,param1=D:\Workspace\Example

预期流程是:Launcher 先得到 param1,再把它作为插件的第二个参数传入。

失败尝试代码:launcher.bat

⚠️ 以下是失败尝试代码。问题集中在 call 插件时的跨脚本参数传递。

@echo off
chcp 65001 >nul
setlocal EnableExtensions EnableDelayedExpansion

set "INSTALL_DIR=%~dp0"
set "CONFIG_DIR=%INSTALL_DIR%config"
set "USER_CONFIG_DIR=%APPDATA%\Launcher\config"

set "RAW_URL=%~1"
if not defined RAW_URL (
    echo [launcher] Missing URL argument.
    exit /b 1
)

:: 解析 URL
set "PARAMS=!RAW_URL!"
set "PARAMS=!PARAMS:launcher://open/=!"
set "PARAMS=!PARAMS:launcher://open?=!"
set "PARAMS=!PARAMS:launcher://open=!"
if defined PARAMS if "!PARAMS:~0,1!"=="?" set "PARAMS=!PARAMS:~1!"

set "APP="
set "PARAM1="
set "PARAM2="
set "P=!PARAMS:&=,!"

:parse_loop
if not defined P goto parse_done
for /f "tokens=1* delims=," %%A in ("!P!") do (
    set "PAIR=%%A"
    set "P=%%B"
    call :trim PAIR "!PAIR!"
    if /i "!PAIR:~0,4!"=="app=" set "APP=!PAIR:~4!"
    if /i "!PAIR:~0,7!"=="param1=" set "PARAM1=!PAIR:~7!"
    if /i "!PAIR:~0,7!"=="param2=" set "PARAM2=!PAIR:~7!"
)
goto parse_loop

:parse_done
call :trim APP "!APP!"
if not defined APP (
    echo [launcher] Missing app= in URL.
    exit /b 1
)

:: 查找 cfg
set "CFG_FILE="
if exist "%USER_CONFIG_DIR%\!APP!.cfg" (
    set "CFG_FILE=%USER_CONFIG_DIR%\!APP!.cfg"
) else if exist "%CONFIG_DIR%\!APP!.cfg" (
    set "CFG_FILE=%CONFIG_DIR%\!APP!.cfg"
) else (
    echo [launcher] App config not found: !APP!.cfg
    exit /b 1
)

:: 读取 cfg
set "EXE="
set "PLUGIN_TYPE="
set "PLUGIN_PATH="

for /f "usebackq eol=; tokens=1,* delims==" %%K in ("!CFG_FILE!") do (
    set "KEY=%%K"
    set "VAL=%%L"
    call :trim KEY "!KEY!"
    call :trim VAL "!VAL!"
    if /i "!KEY!"=="exe" set "EXE=!VAL!"
    if /i "!KEY!"=="plugin.type" set "PLUGIN_TYPE=!VAL!"
    if /i "!KEY!"=="plugin.path" set "PLUGIN_PATH=!VAL!"
)

if not exist "!EXE!" (
    echo [launcher] EXE not found: !EXE!
    exit /b 1
)

if defined PLUGIN_PATH (
    if not exist "!PLUGIN_PATH!" (
        echo [launcher] Plugin not found: !PLUGIN_PATH!
        exit /b 1
    )

    if /i "!PLUGIN_TYPE!"=="bat" (
        :: 失败边界:解析结果在这里跨脚本传递
        call "!PLUGIN_PATH!" "!CFG_FILE!" "!PARAM1!" "!PARAM2!" "!EXE!"
        set "PLUGIN_EXIT=!ERRORLEVEL!"
    ) else if /i "!PLUGIN_TYPE!"=="ps1" (
        :: PS1 是架构预留形式,不是本次已完成路线
        powershell -NoProfile -ExecutionPolicy Bypass -File "!PLUGIN_PATH!" -Config "!CFG_FILE!" -Param1 "!PARAM1!" -Param2 "!PARAM2!" -Exe "!EXE!"
        set "PLUGIN_EXIT=!ERRORLEVEL!"
    ) else (
        echo [launcher] Unsupported plugin type: !PLUGIN_TYPE!
        exit /b 1
    )

    if not "!PLUGIN_EXIT!"=="0" (
        echo [launcher] Plugin failed: !PLUGIN_EXIT!
        exit /b !PLUGIN_EXIT!
    )
)

start "" "!EXE!"
exit /b 0

:trim
setlocal EnableDelayedExpansion
set "STR=%~2"
:trim_left
if defined STR if "!STR:~0,1!"==" " set "STR=!STR:~1!" & goto trim_left
:trim_right
if defined STR if "!STR:~-1!"==" " set "STR=!STR:~0,-1!" & goto trim_right
endlocal & set "%~1=%STR%"
goto :eof

失败尝试代码:plugin.bat

插件约定接收:

参数预期内容
%1cfg 完整路径
%2Launcher 解析出的 param1
%3Launcher 解析出的 param2
%4exe 完整路径
@echo off
setlocal EnableExtensions EnableDelayedExpansion

set "CFG_FILE=%~1"
set "PARAM1=%~2"
set "PARAM2=%~3"
set "TARGET_EXE=%~4"

if not exist "%CFG_FILE%" exit /b 10
if not exist "%TARGET_EXE%" exit /b 11

set "RESOURCE="
set "ENV_RESOURCE="
set "ENV_PARAM1="
set "ENV_PARAM2="

for /f "usebackq eol=; tokens=1,* delims==" %%K in ("%CFG_FILE%") do (
    set "KEY=%%K"
    set "VAL=%%L"
    if /i "!KEY!"=="resource" set "RESOURCE=!VAL!"
    if /i "!KEY!"=="env.resource" set "ENV_RESOURCE=!VAL!"
    if /i "!KEY!"=="env.param1" set "ENV_PARAM1=!VAL!"
    if /i "!KEY!"=="env.param2" set "ENV_PARAM2=!VAL!"
)

if defined ENV_RESOURCE if defined RESOURCE set "!ENV_RESOURCE!=!RESOURCE!"
if defined ENV_PARAM1 if defined PARAM1 set "!ENV_PARAM1!=!PARAM1!"
if defined ENV_PARAM2 if defined PARAM2 set "!ENV_PARAM2!=!PARAM2!"

:: 应用自己的前处理
if defined PARAM1 >"%TEMP%\launcher-task.txt" echo(%PARAM1%

exit /b 0

预期是 %2%3 能直接取得 Launcher 已经解析好的值,实际却出现:

  • 参数为空。
  • 参数只剩一部分。
  • 带空格的路径被拆开。
  • & 后面的内容丢失。
  • %!、引号等字符经过再次展开后发生变化。

结果是插件无法可靠取得 Launcher 解析出的参数,这条路线失败。

第一次失败的核心原因

失败不是发生在“浏览器有没有唤起 Launcher”,也不是发生在“cfg 能不能找到”。这些步骤都可以正常执行。

真正的失败边界是:

launcher.bat 中的变量
        ↓
call plugin.bat "参数"
        ↓
plugin.bat 中的 %1、%2、%3...

同一个参数在这里会再次经过 CMD 的解释规则:

  • %n 会被当作位置参数。
  • %VAR% 会发生百分号展开。
  • !VAR! 会受到延迟展开影响。
  • & 可能被当成命令分隔符。
  • 空格依赖引号保持整体。
  • call 还可能带来再次展开。

因此,Launcher 内部看到的正确值,不等于插件脚本最终收到的值。

参数跨越的解释层越多,问题越明显:

浏览器 → 注册表 → launcher.bat → call → plugin.bat

这就是第一次失败的本质:

脚本调用脚本时,解析好的参数在调用边界发生丢失或变形。

第二次尝试:将 Launcher 改成 C# EXE

BAT Launcher 传参失败后,下一步不是继续修补 BAT,而是尝试把 Launcher 改写为 C#,再打包成 EXE。

新的设想是:

浏览器
  → launcher.exe
  → C# 解析 URL、读取 cfg
  → 调用 plugin.bat
  → 等待插件结束
  → 启动 App

C# 可以稳定接收和解析浏览器 URL,也能更清楚地控制进程。但整个架构仍然保留 BAT 插件,所以参数最终还是要跨过:

C# ProcessStartInfo.Arguments → Windows Shell/CMD → plugin.bat 的 %1、%2、%3

失败尝试代码:launcher.cs

⚠️ 以下代码能够编译,不代表插件化链路已经成功。失败边界位于 ProcessStartInfo 调用 BAT 插件的位置。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;

internal static class Program
{
    private static int Main(string[] args)
    {
        try
        {
            return Run(args);
        }
        catch (Exception ex)
        {
            File.WriteAllText(
                Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "launcher-debug.log"),
                DateTime.Now + " ERROR " + ex);
            return 99;
        }
    }

    private static int Run(string[] args)
    {
        string url = args.Length > 0 ? args[0] : string.Empty;
        if (string.IsNullOrWhiteSpace(url)) return 1;

        Uri uri;
        if (!Uri.TryCreate(url, UriKind.Absolute, out uri)) return 2;

        Dictionary<string, string> query = ParseQuery(uri.Query);
        string app;
        if (!query.TryGetValue("app", out app) || string.IsNullOrWhiteSpace(app))
            return 3;

        string baseDir = AppDomain.CurrentDomain.BaseDirectory;
        string userCfg = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "Launcher", "config", app + ".cfg");
        string localCfg = Path.Combine(baseDir, "config", app + ".cfg");
        string cfgPath = File.Exists(userCfg) ? userCfg : localCfg;
        if (!File.Exists(cfgPath)) return 4;

        Dictionary<string, string> cfg = ReadConfig(cfgPath);
        string exe;
        if (!cfg.TryGetValue("exe", out exe) || !File.Exists(exe)) return 5;

        string pluginPath;
        if (cfg.TryGetValue("plugin.path", out pluginPath) && File.Exists(pluginPath))
        {
            string param1 = query.ContainsKey("param1") ? query["param1"] : string.Empty;
            string param2 = query.ContainsKey("param2") ? query["param2"] : string.Empty;

            // 失败边界:C# 仍需把参数重新拼成命令行交给 BAT 插件
            var plugin = Process.Start(new ProcessStartInfo
            {
                FileName = pluginPath,
                Arguments = Quote(cfgPath) + " " + Quote(param1) + " " +
                            Quote(param2) + " " + Quote(exe),
                UseShellExecute = true,
                WindowStyle = ProcessWindowStyle.Hidden
            });

            if (plugin == null) return 6;
            if (!plugin.WaitForExit(30000)) return 7;
            if (plugin.ExitCode != 0) return plugin.ExitCode;
        }

        Process.Start(new ProcessStartInfo
        {
            FileName = exe,
            UseShellExecute = true
        });

        return 0;
    }

    private static Dictionary<string, string> ParseQuery(string query)
    {
        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        string text = query.TrimStart('?').Replace(',', '&');

        foreach (string pair in text.Split('&'))
        {
            int index = pair.IndexOf('=');
            if (index <= 0) continue;

            string key = Uri.UnescapeDataString(pair.Substring(0, index));
            string value = Uri.UnescapeDataString(pair.Substring(index + 1));
            result[key] = value;
        }

        return result;
    }

    private static Dictionary<string, string> ReadConfig(string path)
    {
        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        foreach (string line in File.ReadAllLines(path))
        {
            string text = line.Trim();
            if (text.Length == 0 || text.StartsWith(";") || text.StartsWith("#"))
                continue;

            int index = text.IndexOf('=');
            if (index <= 0) continue;

            result[text.Substring(0, index).Trim()] =
                text.Substring(index + 1).Trim().Trim('"');
        }

        return result;
    }

    private static string Quote(string value)
    {
        return "\"" + (value ?? string.Empty).Replace("\"", "\\\"") + "\"";
    }
}

编译示例:

%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe /nologo /target:exe /out:launcher.exe launcher.cs

为什么换成 EXE 还是失败?

C# 解决的是 Launcher 自己解析 URL 的问题,但没有改变插件接口。

在 C# 中,参数仍被拼接成一个命令行字符串:

Arguments = Quote(cfgPath) + " " + Quote(param1) + " " + Quote(param2)

随后 BAT 插件仍然通过位置参数读取:

set "PARAM1=%~2"
set "PARAM2=%~3"

也就是说,调用链虽然从:

launcher.bat → plugin.bat

变成了:

launcher.exe → plugin.bat

但最关键的“外部程序调用脚本,并通过命令行传递复杂参数”仍然存在。

实际结果仍然是:

  • EXE 能收到浏览器 URL。
  • EXE 能解析 AppKey。
  • EXE 能找到 cfg。
  • EXE 能启动 BAT 插件。
  • 但 BAT 插件仍不能可靠得到解析后的完整参数。

因此,打包 EXE 没有解决本质问题,只是把发生参数丢失的位置向后移动了一层。

两次失败的真实时间线

第一版
浏览器
  → launcher.bat 解析成功
  → call plugin.bat
  → 插件参数丢失/变形
  → 失败

第二版
浏览器
  → launcher.exe 解析成功
  → ProcessStartInfo 调用 plugin.bat
  → 插件参数仍然丢失/变形
  → 失败

最终回退
浏览器
  → launcher.bat
  → 在同一个脚本上下文中解析、特殊处理并启动 App
  → 不再把解析后的参数交给另一个脚本

失败结论

这次尝试没有实现插件化 Launcher。

可以确认的根因是:

解析后的参数需要从 Launcher 再传给插件脚本;跨脚本调用时,参数受到命令行解析、引号、空格和特殊字符影响,发生丢失或变形。

BAT 改成 C# 后仍然失败,是因为插件依然是 BAT,参数依然需要通过命令行进入脚本位置参数。本质边界没有被移除。

本文不再把失败原因扩展为未经验证的进程继承、应用读取时机等推测。现有证据只能确定:

Launcher 中解析正确
≠
插件脚本中接收正确

当前实际方案

最终能够落地的是:

一个 launcher.bat
  + 一组 cfg
  + 少量应用特殊处理

解析、配置读取、特殊处理和 App 启动都留在同一个脚本上下文中,不再把解析结果传给另一个插件脚本。

完整实现见:浏览器唤起桌面端应用(终极篇)

终极目标

插件化的职责划分仍然是希望达到的方向:

Launcher 负责编排
cfg 负责本机配置
插件负责应用差异
App 在插件完成后启动

但本次没有解决 Launcher 与脚本插件之间的可靠参数通道,所以它还不是“终极方案”,只能是“终极目标”。

如果未来继续实现,首先需要重新设计参数交付方式,而不是继续拼接命令行字符串。例如使用不会被 CMD 再次解释的结构化中间文件或其他明确接口。只有插件能够稳定、完整地取得参数,后续架构才有继续讨论的基础。

✅ 总结

这次尝试最重要的结论不是“BAT 不够强”,也不是“换成 EXE 就能解决”,而是:

只要 Launcher 仍通过命令行调用脚本插件,复杂参数就可能在调用边界丢失。

第一次用 BAT 实现 Launcher,失败在 launcher.bat → plugin.bat

第二次用 C# 打包 EXE,失败在 launcher.exe → plugin.bat

两次失败是同一个问题,不是两个独立问题。

因此当前回退到单一 launcher.bat 完成解析、配置读取、特殊处理和 App 启动;插件化架构保留为尚未实现的终极目标。