[!WARNING] 本方案实施失败。
本文记录的是一次插件化 Launcher 的失败尝试,以及希望继续实现的终极目标。
下文代码用于还原当时的设计与问题,注意这不是已经验证成功的可运行教程。
————一次没有完成的插件化启动架构
在终极篇中,我们最终使用下面的方案完成了实际落地:
浏览器 → launcher.bat → 解析参数 → 读取 cfg → 特殊处理 → 启动 App
这个方案能够工作,但少量应用差异仍然写在 launcher.bat 中。
最初设想的终极架构更进一步:Launcher 不再处理具体应用逻辑,而是把解析后的参数交给应用插件。
浏览器
|
| launcher://open?app=<AppKey>¶m1=***¶m2=***
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
插件约定接收:
| 参数 | 预期内容 |
|---|---|
%1 | cfg 完整路径 |
%2 | Launcher 解析出的 param1 |
%3 | Launcher 解析出的 param2 |
%4 | exe 完整路径 |
@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 启动;插件化架构保留为尚未实现的终极目标。