一个不能上架应用商店的扩展,怎么发给几十号非技术同事?

约束很硬:

  • 没有管理员权限(所以任何需要提权的方案直接出局);
  • 很多人不用命令行(所以要有双击就能跑的入口);
  • 要能更新,而且更新不能比安装更麻烦。

最后的形态是一条 PowerShell:

终端窗口
irm https://example.com/install.ps1 | iex

外加一个给不用命令行的人双击的 .cmd,它只做转发——去服务器拉同一份 .ps1 来跑,所以本地那份 .cmd 永远不会过期。

脚本做什么

读安装目录里的 manifest.json → 判断首次还是更新
↓
[1/4] 下载最新 zip
[2/4] 解压 + 校验(找 manifest.json 确认是合法扩展包)
[3/4] 部署到固定目录(先清空再拷贝,避免残留旧文件)
[4/4] 清理临时文件
↓
打印本次更新内容
↓
首次 → 开扩展管理页 → 弹出资源管理器并选中扩展文件夹 → 提示拖进去
更新 → 只开扩展管理页 → 提示点「重新加载」
↓
匿名上报一条安装记录(fire-and-forget)

[2/4] 里那句「找 manifest.json 确认」不是多余的:zip 里可能多套一层目录(取决于打包时的当前目录),所以要递归找到含 manifest.json 的那一层,把它当真正的根。

终端窗口
$maniFile = Get-ChildItem -Path $TmpExtract -Filter 'manifest.json' -Recurse |
Select-Object -First 1
if (-not $maniFile) {
Write-Host ' [错误] 压缩包内未找到 manifest.json,不是合法的扩展包。' -ForegroundColor Red
return
}
$srcRoot = $maniFile.DirectoryName
$newVer = (Get-Content $maniFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json).version

顺带也解决了「装了但浏览器说不是扩展」这类模糊失败——在部署之前就明确报错。

★ 老用户绝不搬家

这是整个脚本里最贵的一条。

某个版本我把安装目录往下挪了一层(从 %USERPROFILE%\ext 挪到 %USERPROFILE%\vendor\ext),为的是资源管理器打开父目录时能看见并拖走扩展文件夹本身。

如果更新脚本顺手把老用户也迁过去,他们的扩展会当场失效——浏览器认的是加载时那个绝对路径,目录一搬,扩展就断了,而且用户完全不知道发生了什么。

所以:

终端窗口
# 旧布局里有 manifest.json → 就继续用旧目录,什么都不动
$legacyVer = Get-LocalVersion $LegacyDir
if ($legacyVer) {
$InstallDir = $LegacyDir
Write-Host (' 检测到旧版安装位置,将原地更新:' + $InstallDir) -ForegroundColor DarkGray
}

用户之间路径不一致是预期行为,不是 bug。 卸载脚本两个位置都清,父目录只在空的时候才删。

任何「顺手把历史数据规整一下」的想法,在分发场景里都要先问一句:规整失败的那部分用户,会以什么方式发现? 如果答案是「功能突然没了、而且没有提示」,那就别规整。

chrome:// 打不开,只能抢焦点粘地址

这是 Windows 上做浏览器扩展分发绕不过去的一个坎:

chrome://extensions 这类地址无法用命令行参数打开。 浏览器会忽略它(安全设计)。

唯一可行的办法是模拟用户操作:

终端窗口
Set-Clipboard $Url
Activate-Window $proc # 抢前台焦点
$wshell.SendKeys('^t') # Ctrl+T 新标签(浏览器已在跑时才需要)
$wshell.SendKeys('^l') # Ctrl+L 聚焦地址栏
$wshell.SendKeys('^a') # 全选,清掉占位内容
$wshell.SendKeys('^v') # 粘贴
$wshell.SendKeys('{ENTER}')

「抢前台焦点」还要绕过 Windows 的前台锁定保护——不绕的话 SetForegroundWindow 会静默失败,你的按键会打到 PowerShell 窗口上(于是用户看到一串 ^l^a^v 被输进了控制台)。

这套东西脆,但没有替代方案。所以每一步都要能失败得优雅:抢焦点失败 → 提示用户手动打开;粘贴失败 → 地址还在剪贴板里,告诉他自己粘。

三个「顺序不能换」

① 先开扩展页,再弹文件夹

explorer.exe 会抢前台焦点。如果先弹文件夹,后面那一串 Ctrl+L / Ctrl+V 全会打到资源管理器窗口上,导航直接失败。

Open-ExtPage ← 先
Open-InstallFolder ← 后(explorer 抢焦点,但此时已经不需要按键了)

② 剪贴板:先放 URL,再放安装目录

Send-UrlToAddressBar 要靠剪贴板粘贴。所以安装目录只能在开完扩展页之后再写剪贴板,否则会把 URL 顶掉——用户按 Ctrl+V 粘出来的是一个网址而不是目录。

而且只在扩展页打开成功时才覆盖:失败的话剪贴板里那个 URL 还要留给降级提示用。

③ 选浏览器的 Read-Host 会阻塞

装了两个浏览器时,脚本会问用户装到哪个。这一步是阻塞的:

Open-ChosenBrowserExtPage ← 在这里等用户输编号
↓ (输完回车)
开扩展页
↓
Open-InstallFolder ← 到这一步资源管理器才弹出

于是有人报「资源管理器没自动弹出」。那不是 bug——他卡在选浏览器那一步还没输编号。

排查这类问题的判定顺序:① 是不是卡在 Read-Host;② 目录里已经有 manifest.json(= 装过了)→ 走的是更新分支,按设计就不开文件夹;③ 才轮到怀疑 explorer /select 本身。

安装统计:fire-and-forget

部署完成后匿名上报一条记录:版本、升级前版本、是否首装、渠道、系统 build。

终端窗口
try {
Invoke-RestMethod -Uri $PingUrl -Method Post -Body $json -ContentType 'application/json' -TimeoutSec 5
} catch { } # ★ 全吞。统计失败绝不能拖累或中断安装

三条边界:

  • 5 秒超时 + 异常全吞。 内网机器连不到公网时静默跳过,用户完全无感。
  • 不含姓名、工号、主机名、用户名、IP → 定位不到具体个人。
  • 来源 IP 刻意不由脚本上报:请求到达服务端时公网 IP 本来就在代理头里,让客户端再去问第三方查 IP 纯属绕远路加一个失败点。

还有一条认知上的:这是「安装事件流」,不是「在用版本分布」。 装完再也不升级的人,永远停在他那条旧记录上。后者得由扩展自身上报。

打印「这次改了什么」

安装脚本跑完打印本次更新内容——这是用户唯一会读的地方。

终端窗口
function Show-Changelog {
# 读包内的 changelog.json,按新版本号取对应条目
# 读不到 / 没有对应版本 → 静默跳过(兼容老包)
}

配套的是打包时的一道硬闸:更新日志里没有当前版本的条目,直接打包失败。

因为漏写更新日志从来不会报错——不写,打包照过、安装照跑,只是用户装完看不到「这次改了什么」,而那恰恰是他最该看见的一句话。

凡是「漏了会静默出错」的事,都做成闸门。

三条分发渠道,一份业务逻辑

渠道入口谁用
公网irm https://example.com/install.ps1 | iex会用命令行的
双击下载 i.cmd 双击不用命令行的
内网同一份脚本的内网副本连不到公网的

关键是只有一份 .ps1。 .cmd 不重写任何逻辑,它只做一件事:转手调 PowerShell 跑那份 .ps1。

用 batch 重写那些逻辑(读 JSON、操作剪贴板、抢焦点)是做不到的——batch 里没有对应的能力,硬翻必然行为漂移,然后你有两个入口要同步维护。

i.cmd 还会云端失败自动降级内网,所以一份文件走遍内外网。

最后:别双击那个 .ps1

这条要写进脚本的注释里,因为它反直觉。

.ps1 是无 BOM 的 UTF-8(必须如此,带 BOM 会让 irm | iex 报错)。而 Windows PowerShell 5.1 从磁盘读无 BOM 文件时按系统 ANSI 代码页解码——中文 Windows 上是 GBK。

于是中文注释会把下一行真代码吃掉,实测这个脚本丢 58 行。

好消息是丢行后语法必然崩坏,PowerShell 先整体解析再执行,所以结果是「大声报错、一行不执行」——不会装坏,但也装不上。

要本地跑就用 .cmd(它显式按 UTF-8 解码)。完整的编码地狱在另一篇。


可以直接抄走的模板:/showcase/ps-ext-installer —— 安装 / 卸载 / .cmd 引导器三个文件,域名全部用占位符。

回头看,这套东西真正难的从来不是「把文件放到位」——那是十行 PowerShell。

难的是让一个不知道什么是扩展的人,在自己的机器上,一次成功。剩下那五百行全在处理这件事。