Udesk 系列 · 示范代码
PowerShell 扩展安装器模板
一条命令装 / 更新 load-unpacked 浏览器扩展 —— 免管理员、首次与更新分流、老用户绝不搬家
PowerShell 扩展安装器模板
给不能上架应用商店的浏览器扩展做分发:一条 PowerShell 完成首次安装与后续更新,不需要管理员权限。
irm https://example.com/install.ps1 | iex三个文件:install.ps1(安装 / 更新)、uninstall.ps1(卸载)、i.cmd(给不用命令行的人双击)。域名和产品名全部用占位符,改三处即可用。
📖 为什么是这个形态、以及那些「顺序不能换」的细节 → irm | iex 一条命令装浏览器扩展
⚠ 编码地狱(BOM / GBK 吞行 /
.cmd三条铁律)→ PowerShell 5.1 的编码地狱
要改的三处
$BaseUrl = 'https://example.com/product' # 你的分发地址$PingUrl = 'https://pb.example.com/api/collections/installs/records' # 安装统计(不要就删掉)$InstallDir = Join-Path (Join-Path $env:USERPROFILE 'vendor') 'product-extension'它替你处理掉的几件事
| 首次 vs 更新分流 | 首次开扩展页 + 弹资源管理器选中文件夹;更新只提示点「重新加载」,不动文件夹也不动剪贴板 |
| ★ 老用户绝不搬家 | 旧布局里有 manifest.json 就继续用旧目录。浏览器认的是加载时那个绝对路径,搬走 = 扩展当场失效 |
| 包结构校验 | 递归找 manifest.json 确定真正的根(zip 可能多套一层),找不到就在部署之前明确报错 |
| 打印更新日志 | 读包内 changelog.json 按版本取条目;没有就静默跳过(兼容老包) |
| 匿名安装统计 | 5 秒超时、异常全吞、不含任何可定位到个人的字段 |
| 抢焦点粘地址 | chrome:// 无法用命令行参数打开,只能模拟用户;附带绕过 Windows 前台锁定 |
三个「顺序不能换」
代码里都标了注释,这里列出来免得改的时候顺手调换:
- 先开扩展页,再弹资源管理器。
explorer会抢前台焦点,反过来的话Ctrl+L/Ctrl+V会打到资源管理器窗口上。 - 剪贴板先放 URL,最后才放安装目录。 粘贴地址要用剪贴板;提前写目录会把 URL 顶掉,用户粘出来的是一个网址。而且只在扩展页打开成功时才覆盖——失败时那个 URL 还要留给降级提示。
- 卸载只在父目录为空时才删它。 那个目录里可能有别的东西。
编码:这份模板保持纯 ASCII
.ps1 必须是无 BOM 的 UTF-8(带 BOM 会让 irm | iex 报错),而 Windows PowerShell 5.1 从磁盘读无 BOM 文件时按系统 ANSI 代码页解码——中文 Windows 上是 GBK,会把非 ASCII 注释后面的换行吃掉,下一行真代码随之消失。
有 BOM 坏管道、没 BOM 坏磁盘,没有两全其美的编码。所以这份模板一个非 ASCII 字符都没有,从根上让问题不可能发生。
改完跑这四条:
# 1) 语法能解析(不执行)powershell -NoProfile -Command "[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'install.ps1').Path, [ref]$null, [ref]$errs); $errs.Count"
# 2) 没被偷偷加上 BOM(前三字节不能是 efbbbf)head -c 3 install.ps1 | xxd -p
# 3) 非 ASCII 字节数必须为 0LC_ALL=C grep -cP '[^\x00-\x7F]' install.ps1 uninstall.ps1 i.cmd
# 4) 扫注释里的裸元字符grep -nE '^(rem|echo)' i.cmd | grep -E '[^^][|&<>]' | grep -v '>nul'四条全过也只说明「没坏在已知的地方」,还得真机装一次——语法过 ≠ 装得上。
验证
两个 .ps1 过 [System.Management.Automation.Language.Parser]::ParseFile(0 处语法错误);三个文件非 ASCII 字节数为 0、均无 BOM;i.cmd 注释裸元字符扫描为空。
写这份模板时顺带推翻了一条我们自己传了很久的「铁律」:现代 CMD 的
rem其实是保护元字符的(Windows 11 上对|、&、>、<四种字符、顶层与括号块内各试一遍,全部无副作用)。当初那次真实故障更可能是「非 ASCII 把行劈开」被误归因了。经过写在文章里。
代码
# =============================================================================# <Product> -- install / update (load-unpacked, no admin rights required)# =============================================================================# One command does both first install and later updates; the two paths differ:# first install => deploy files -> open the browser extensions page# -> open Explorer with the folder selected -> user drags it in# update => deploy files -> open the extensions page# -> user clicks "Reload" on the card (no folder, no clipboard)## Usage (copy the whole line into PowerShell):# irm https://example.com/install.ps1 | iex## To run it from disk, download i.cmd and double-click that -- NOT this .ps1.# This file is BOM-less UTF-8 (a BOM breaks `irm | iex`), and Windows PowerShell# 5.1 decodes BOM-less files from disk using the system ANSI code page. On a# Chinese Windows that is GBK, which swallows line breaks after non-ASCII# comments and makes real code vanish. i.cmd decodes as UTF-8 explicitly.# => This template keeps every comment ASCII so the problem cannot occur.# =============================================================================
$ErrorActionPreference = 'Stop'
# ---- Force UTF-8 console output ------------------------------------------# Even when the script itself decoded correctly, an old conhost may render# Write-Host using the system ANSI code page. Set both explicitly.try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 $OutputEncoding = [Text.Encoding]::UTF8} catch { }
# ---- Configuration -------------------------------------------------------$BaseUrl = 'https://example.com/product'$ZipUrl = "$BaseUrl/product-latest.zip"$PingUrl = 'https://pb.example.com/api/collections/installs/records'$Channel = 'public'
$ParentDir = Join-Path $env:USERPROFILE 'vendor'$InstallDir = Join-Path $ParentDir 'product-extension'# Layout used before the folder was moved one level down. Existing users stay# where they are -- see "never relocate" below.$LegacyDir = Join-Path $env:USERPROFILE 'product-extension'
$TmpZip = Join-Path $env:TEMP 'product-latest.zip'$TmpExtract = Join-Path $env:TEMP 'product-extract'
Write-Host ''Write-Host ' <Product> installer' -ForegroundColor CyanWrite-Host ''
# ---- Read the installed version ------------------------------------------function Get-LocalVersion { param([string]$Dir) $mani = Join-Path $Dir 'manifest.json' if (-not (Test-Path $mani)) { return $null } try { return (Get-Content $mani -Raw -Encoding UTF8 | ConvertFrom-Json).version } catch { return $null }}
# ---- NEVER RELOCATE AN EXISTING INSTALL ----------------------------------# The browser binds to the absolute path used when the extension was loaded.# Moving the folder silently breaks it, with no message the user can act on.# So: if the legacy layout has a manifest, keep using it and change nothing.$legacyVer = Get-LocalVersion $LegacyDirif ($legacyVer) { $InstallDir = $LegacyDir $ParentDir = $env:USERPROFILE Write-Host (' Existing install detected, updating in place: ' + $InstallDir) -ForegroundColor DarkGray}
$oldVer = Get-LocalVersion $InstallDir$isFirst = [string]::IsNullOrEmpty($oldVer)if (-not $isFirst) { Write-Host (' Installed version: ' + $oldVer) -ForegroundColor DarkGray }
# ---- 1/4 download --------------------------------------------------------Write-Host (' [1/4] Downloading: ' + $ZipUrl)try { Invoke-WebRequest -Uri $ZipUrl -OutFile $TmpZip -UseBasicParsing} catch { Write-Host (' [ERROR] Download failed: ' + $_.Exception.Message) -ForegroundColor Red return}
# ---- 2/4 extract and validate --------------------------------------------Write-Host ' [2/4] Extracting and validating'if (Test-Path $TmpExtract) { Remove-Item $TmpExtract -Recurse -Force }New-Item -ItemType Directory -Path $TmpExtract -Force | Out-Nulltry { Expand-Archive -Path $TmpZip -DestinationPath $TmpExtract -Force} catch { Write-Host (' [ERROR] Extract failed: ' + $_.Exception.Message) -ForegroundColor Red Remove-Item $TmpZip -Force -ErrorAction SilentlyContinue return}
# The zip may contain an extra wrapper directory depending on how it was made.# Locate the level that actually holds manifest.json and treat that as the root.# This also turns "installed but the browser says it is not an extension" into# an explicit failure BEFORE anything is deployed.$maniFile = Get-ChildItem -Path $TmpExtract -Filter 'manifest.json' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1if (-not $maniFile) { Write-Host ' [ERROR] No manifest.json inside the archive; not a valid extension package.' -ForegroundColor Red Remove-Item $TmpZip -Force -ErrorAction SilentlyContinue Remove-Item $TmpExtract -Recurse -Force -ErrorAction SilentlyContinue return}$srcRoot = $maniFile.DirectoryName$newVer = (Get-Content $maniFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json).versionWrite-Host (' Package version: ' + $newVer) -ForegroundColor DarkGray
# ---- 3/4 deploy (clear first, so stale files cannot survive) -------------Write-Host (' [3/4] Deploying to: ' + $InstallDir)if (Test-Path $InstallDir) { Remove-Item $InstallDir -Recurse -Force }New-Item -ItemType Directory -Path $InstallDir -Force | Out-NullCopy-Item -Path (Join-Path $srcRoot '*') -Destination $InstallDir -Recurse -Force
# ---- 4/4 cleanup ---------------------------------------------------------Write-Host ' [4/4] Cleaning up'Remove-Item $TmpZip -Force -ErrorAction SilentlyContinueRemove-Item $TmpExtract -Recurse -Force -ErrorAction SilentlyContinue
Write-Host ''Write-Host (' [DONE] Files deployed: ' + $InstallDir + ' (version ' + $newVer + ')') -ForegroundColor GreenWrite-Host ''
# ---- Print what changed in this release ----------------------------------# The only place most users will ever read a changelog. Missing file or missing# entry => stay silent (keeps older packages working).function Show-Changelog { param([string]$Dir, [string]$Version) $f = Join-Path $Dir 'changelog.json' if (-not (Test-Path $f)) { return } try { $j = Get-Content $f -Raw -Encoding UTF8 | ConvertFrom-Json $entry = $j.versions.PSObject.Properties[$Version] # PS 5.1 friendly lookup if (-not $entry) { return } Write-Host (' What changed in ' + $Version + ':') -ForegroundColor Cyan foreach ($item in $entry.Value.items) { Write-Host (' - ' + $item) } Write-Host '' } catch { }}Show-Changelog $InstallDir $newVer
# ---- Anonymous install ping (fire and forget) ----------------------------# No name, no user id, no hostname, no username, no IP -- cannot identify a person.# Source IP is deliberately NOT sent: the server already sees it on the request,# so asking a third party for it would just add a failure point.# ANY failure is swallowed: statistics must never block or slow down an install.## WARNING: add the column on the server BEFORE shipping a script that sends it.# Many backends silently drop unknown fields -- the POST returns 200, the value# never lands, and nothing tells you.function Send-InstallPing { param([string]$Version, [string]$PrevVersion, [bool]$IsFirst) try { $body = @{ version = $Version prev_version = $PrevVersion is_first = $IsFirst channel = $Channel os_build = [string][Environment]::OSVersion.Version } | ConvertTo-Json -Compress Invoke-RestMethod -Uri $PingUrl -Method Post -Body $body ` -ContentType 'application/json' -TimeoutSec 5 | Out-Null } catch { }}Send-InstallPing -Version $newVer -PrevVersion $oldVer -IsFirst $isFirst
# ---- Open the extensions page --------------------------------------------# chrome:// and edge:// URLs CANNOT be opened via command line arguments.# The only way is to simulate the user: focus the window, Ctrl+L, paste, Enter.$ExtPageUrl = 'chrome://extensions'
# Windows foreground lock: without this, SetForegroundWindow fails silently and# the keystrokes land in the PowerShell window instead.Add-Type @'using System;using System.Runtime.InteropServices;public static class Fg { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); public static bool Focus(IntPtr hWnd) { uint target = GetWindowThreadProcessId(hWnd, IntPtr.Zero); uint self = GetCurrentThreadId(); AttachThreadInput(self, target, true); ShowWindow(hWnd, 9); // SW_RESTORE bool ok = SetForegroundWindow(hWnd); AttachThreadInput(self, target, false); return ok; }}'@ -ErrorAction SilentlyContinue
function Open-ExtPage { param([string]$Exe, [string]$Url) try { $running = Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($Exe)) -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 if (-not $running) { Start-Process $Exe Start-Sleep -Seconds 2 $running = Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($Exe)) -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 } if (-not $running) { return $false }
Set-Clipboard $Url if (-not [Fg]::Focus($running.MainWindowHandle)) { return $false } Start-Sleep -Milliseconds 300
$wshell = New-Object -ComObject WScript.Shell $wshell.SendKeys('^t'); Start-Sleep -Milliseconds 250 $wshell.SendKeys('^l'); Start-Sleep -Milliseconds 150 $wshell.SendKeys('^a'); Start-Sleep -Milliseconds 100 $wshell.SendKeys('^v'); Start-Sleep -Milliseconds 200 $wshell.SendKeys('{ENTER}') return $true } catch { return $false }}
function Find-Browser { $candidates = @( (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'), (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'), (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe') ) foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } } return $null}
$browser = Find-Browser$opened = $falseif ($browser) { $opened = Open-ExtPage $browser $ExtPageUrl }
if (-not $opened) { Write-Host ' Could not open the extensions page automatically.' -ForegroundColor Yellow Write-Host (' Please open it yourself (the URL is already on your clipboard): ' + $ExtPageUrl)}
# ---- ORDER MATTERS -------------------------------------------------------# 1) Explorer steals foreground focus. It MUST come AFTER the extensions page,# otherwise the Ctrl+L / Ctrl+V keystrokes above land in the Explorer window.# 2) The clipboard held the URL for the paste above, so only overwrite it with# the install path now -- and only when the page actually opened, because a# failed open still needs that URL for the fallback message printed above.if ($isFirst) { Start-Process explorer.exe -ArgumentList ('/select,"' + $InstallDir + '"') if ($opened) { Set-Clipboard $InstallDir } Write-Host '' Write-Host ' First install:' -ForegroundColor Cyan Write-Host ' 1. Turn on "Developer mode" (top right of the extensions page)' Write-Host ' 2. Drag the selected folder from Explorer onto that page' Write-Host (' (the path is also on your clipboard: ' + $InstallDir + ')')} else { Write-Host '' Write-Host ' Update:' -ForegroundColor Cyan Write-Host ' Click "Reload" on the extension card, or restart the browser.' Write-Host (' Updated ' + $oldVer + ' -> ' + $newVer)}Write-Host ''# =============================================================================# <Product> -- uninstall (removes deployed files, no admin rights required)# =============================================================================# Usage:# irm https://example.com/uninstall.ps1 | iex## This removes the files. The browser still shows the extension card until the# user removes it there -- a script cannot unload a load-unpacked extension.# Say so explicitly; a silent half-uninstall is worse than none.# =============================================================================
$ErrorActionPreference = 'Stop'try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 $OutputEncoding = [Text.Encoding]::UTF8} catch { }
$ParentDir = Join-Path $env:USERPROFILE 'vendor'$InstallDir = Join-Path $ParentDir 'product-extension'# Clean BOTH layouts: users installed before the move still live in the old one.$LegacyDir = Join-Path $env:USERPROFILE 'product-extension'
Write-Host ''Write-Host ' <Product> uninstaller' -ForegroundColor CyanWrite-Host ''
$removed = @()foreach ($dir in @($InstallDir, $LegacyDir)) { if (Test-Path $dir) { try { Remove-Item $dir -Recurse -Force $removed += $dir Write-Host (' Removed: ' + $dir) -ForegroundColor Green } catch { Write-Host (' [ERROR] Could not remove ' + $dir + ': ' + $_.Exception.Message) -ForegroundColor Red Write-Host ' Close the browser and run this again.' -ForegroundColor Yellow } }}
# Only remove the parent when it is EMPTY -- it may hold unrelated things.if ((Test-Path $ParentDir) -and -not (Get-ChildItem $ParentDir -Force)) { try { Remove-Item $ParentDir -Force; Write-Host (' Removed empty parent: ' + $ParentDir) -ForegroundColor DarkGray } catch { }}
Write-Host ''if ($removed.Count -eq 0) { Write-Host ' Nothing to remove -- no install found in either location.' -ForegroundColor Yellow} else { Write-Host ' Files removed.' -ForegroundColor Green Write-Host '' Write-Host ' One manual step left:' -ForegroundColor Cyan Write-Host ' Open chrome://extensions and click "Remove" on the card.' Write-Host ' A script cannot unload an extension the browser already loaded.'}Write-Host ''rem i.cmd@echo offsetlocalrem ===========================================================================rem PRODUCT -- install / update bootstrapper (CMD)rem ===========================================================================rem WHAT THIS ISrem The no-command-line entry point. Save it anywhere and double-click.rem It still pulls the latest install.ps1 + package FROM THE SERVER, so arem locally saved copy never goes stale -- it is a launcher, not a snapshot.remrem BUSINESS LOGIC LIVES IN THE PS1, NOT HERErem This file only hands off to PowerShell. install.ps1 stays the singlerem source of truth so the two entry points can never drift apart.rem Do not reimplement clipboard / focus / JSON handling in batch: there isrem no equivalent, and any attempt will behave differently.remrem ######## HARD RULES FOR THIS FILE -- LEARNED THE HARD WAY ################remrem 1. ASCII ONLY. No non-ASCII characters anywhere in this file.rem CMD parses every line using the console code page that is active BEFORErem this file starts running. A UTF-8 non-ASCII character is several bytes,rem and some of those bytes collide with CMD metacharacters, so the linerem gets split mid-character and the tail is executed as a command.rem chcp 65001 CANNOT save this file -- parsing happens before it runs.rem All localized output belongs in the .ps1, which decodes safely.remrem 2. DO NOT PASTE A FULL COMMAND LINE INTO A COMMENT. Modern CMD does keeprem metacharacters inside a rem line inert (verified on Windows 11 for therem pipe, ampersand and both angle brackets, at top level and inside arem parenthesised block). The real hazard is rule 1: such a line tends to berem long and to carry non-ASCII, which is exactly where a line gets split.rem Write plain prose here, and keep runnable examples in the .ps1.remrem 3. BUT REAL OPERATORS MUST STAY BARE. The pipe and the double ampersand onrem the last line are real operators. Escaping them makes find treat themrem as file names and fail with "File not found".rem ###########################################################################
rem chcp is here only so the PS1's own output renders correctly in this window.chcp 65001 >nul 2>&1
set "PS1_URL=https://example.com/product/install.ps1"
echo.echo Fetching the installer...echo.
rem WebClient with an explicit UTF-8 encoding: do not rely on the server sendingrem a charset, and do not rely on the default decoding of irm.powershell -NoProfile -ExecutionPolicy Bypass -Command ^ "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $w=[Net.WebClient]::new(); $w.Encoding=[Text.Encoding]::UTF8; iex ($w.DownloadString('%PS1_URL%'))"
echo.rem Keep the window open when double-clicked, but not when run from a console.echo %CMDCMDLINE% | find /i "/c" >nul && pause设计与踩坑 → irm | iex 一条命令装浏览器扩展 · 编码地狱 → PowerShell 5.1 的编码地狱