# 小飞燕输入法 · 一行安装 # # irm https://writing.freecoding.shop | iex # # 全程静默:不弹任何对话框,子进程窗口一律隐藏。装完不必重启电脑 —— # 新 DLL 立刻生效,只有安装时已经开着的程序还握着旧的一份。 # # 卸载: irm https://writing.freecoding.shop/uninstall | iex $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $Base = 'https://writing.freecoding.shop' $PayloadUrl = "$Base/dl/swallow-payload.zip" $PayloadSha = '3ef5d9bf890597d1d7b780ee216cb6ec9dbaacb077e7e1ffaea17b395ab46184' $Version = '0.17.4' $InstallDir = Join-Path $env:ProgramFiles "Rime\swallow-$Version" $StepTotal = 7 # 步数。注意别用 $total —— PowerShell 变量名不区分大小写 function Show-Banner { # 燕子来自 resource/swallow-512.png:原图是描边线条,用 9px 椭圆核做闭运算把 # 笔画缝隙桥接上(量过:90% 的缝隙在 4px 内,而身体与翅膀之间真正的断开有 # 58px —— 核取 9 正好只连前者不连后者),再从画布外洪水填充补实内部, # 最后按控制台 2:1 的字符高宽比降采样。 # # 每行拆成两截:r[0] 是左边框+燕子,r[1] 是字标+右边框,分别着色再用 # -NoNewline 接起来 —— 一行里要三种颜色只能这么拼。 # 全部单引号串:画里的反斜杠和 $ 都是字面量,双引号串会去展开它们。 $rows = @( ,@('┌──────────────────────────────────────────────────────────────────────────────────────────┐', '') ,@('│ .:*##########**::. ', ' │') ,@('│ #####################*:. ', ' │') ,@('│ :#####*. .########*.. ...:::::::::. ', ' │') ,@('│ *################. ', ' │') ,@('│ .########: .:::. ', ' │') ,@('│ *#########. ', ' │') ,@('│ *###########: ', ' _____ _____ ___ __ __ _ _ _ │') ,@('│ .*###########################***::::... ', ' | ___| ____|_ _| \ \ / // \ | \ | | │') ,@('│ ########..**##########: . ', ' | |_ | _| | | \ V // _ \ | \| | │') ,@('│ ########* .:*###*: ', ' | _| | |___ | | | |/ ___ \| |\ | │') ,@('│ :#####*:. .:*##*:. ', ' |_| |_____|___| |_/_/ \_\_| \_| │') ,@('│ ###* ..::::.... ', ' │') ,@('│ ### ', ' │') ,@('│ *#* ', ' │') ,@('│ :#: ', ' │') ,@('│ .# ', ' │') ,@('│ * ', ' │') ,@('│ . ', ' │') ,@('└──────────────────────────────────────────────────────────────────────────────────────────┘', '') ) $narrowRows = @( ,@('┌──────────────────────────────────────────────────────┐', '') ,@('│ .:*###########**::.. ', ' │') ,@('│ .#######################*:. ', ' │') ,@('│ :******. .##########*::.:::***********. ', ' │') ,@('│ .#######::*#########. ', ' │') ,@('│ ##########:**####*. ', ' │') ,@('│ *#########*. ', ' │') ,@('│ ###########*. ', ' │') ,@('│ *##############:............. ', ' │') ,@('│ .#############################********::::... ', ' │') ,@('│ ######### .:*############. . ', ' │') ,@('│ #########* .:*####*: ', ' │') ,@('│ :#######*: .:**##*:. ', ' │') ,@('│ ####. ..:***::.. ', ' │') ,@('│ ###: .... ', ' │') ,@('│ *## ', ' │') ,@('│ :#* ', ' │') ,@('│ .#: ', ' │') ,@('│ #. ', ' │') ,@('│ * ', ' │') ,@('│ . ', ' │') ,@('│ ', '│') ,@('│ ', ' _____ _____ ___ __ __ _ _ _ │') ,@('│ ', ' | ___| ____|_ _| \ \ / // \ | \ | | │') ,@('│ ', ' | |_ | _| | | \ V // _ \ | \| | │') ,@('│ ', ' | _| | |___ | | | |/ ___ \| |\ | │') ,@('│ ', ' |_| |_____|___| |_/_/ \_\_| \_| │') ,@('└──────────────────────────────────────────────────────┘', '') ) # 侧排需要 92 列。窗口不够宽就退回上下排 —— 框被折断比矮一点难看得多。 $w = 999 try { $w = $Host.UI.RawUI.WindowSize.Width } catch {} $use = if ($w -ge 92) { $rows } else { $narrowRows } Write-Host '' foreach ($r in $use) { if ($r[1] -eq '') { Write-Host $r[0] -ForegroundColor DarkCyan } else { Write-Host $r[0].Substring(0,1) -NoNewline -ForegroundColor DarkCyan Write-Host $r[0].Substring(1) -NoNewline -ForegroundColor Cyan Write-Host $r[1].Substring(0, $r[1].Length - 1) -NoNewline -ForegroundColor Blue Write-Host '│' -ForegroundColor DarkCyan } } Write-Host '' Write-Host ' 小 飞 燕 输 入 法' -ForegroundColor White -NoNewline Write-Host " Swallow IME $Version 三色三选,一按即中" -ForegroundColor DarkGray Write-Host '' } # --- 控制台输出 ----------------------------------------------------------- # 步骤行先原地打出来,做完再回到行首把结果补上,避免每步占两行。 $script:StepNo = 0 $script:StepSw = $null function Step([string]$text) { $script:StepNo++ $script:StepSw = [Diagnostics.Stopwatch]::StartNew() Write-Host (' [{0}/{1}] ' -f $script:StepNo, $StepTotal) -NoNewline -ForegroundColor DarkGray Write-Host $text.PadRight(34) -NoNewline -ForegroundColor Gray } function StepDone([string]$note = '') { $ms = if ($script:StepSw) { $script:StepSw.ElapsedMilliseconds } else { 0 } $t = if ($ms -ge 1000) { '{0:N1}s' -f ($ms / 1000) } else { "${ms}ms" } Write-Host '✓ ' -NoNewline -ForegroundColor Green Write-Host $t.PadRight(8) -NoNewline -ForegroundColor DarkGray if ($note) { Write-Host $note -ForegroundColor DarkGray } else { Write-Host '' } } function StepWarn([string]$note) { Write-Host '! ' -NoNewline -ForegroundColor Yellow Write-Host $note -ForegroundColor Yellow } function Die([string]$text) { Write-Host '' Write-Host ' × ' -NoNewline -ForegroundColor Red Write-Host $text -ForegroundColor Red Write-Host '' exit 1 } Show-Banner # --- 前置检查 ------------------------------------------------------------- if ([Environment]::OSVersion.Version.Major -lt 10) { Die '需要 Windows 10 或更新的系统。' } if (-not [Environment]::Is64BitOperatingSystem) { Die '这个程序包只含 64 位版本。32 位系统请从源码构建。' } # 提权:irm | iex 跑在当前会话里,没有管理员权限注册不了文字服务。 # 重新以管理员身份把同一条命令跑一遍,比让用户自己去查怎么提权省事。 $admin = ([Security.Principal.WindowsPrincipal] ` [Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $admin) { Write-Host ' 需要管理员权限来注册输入法,正在请求提权…' -ForegroundColor Yellow Write-Host '' try { Start-Process powershell -Verb RunAs -ArgumentList ` '-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-Command', "irm $Base | iex" } catch { Die '提权被拒绝。右键「以管理员身份运行 PowerShell」后再执行同一条命令。' } return } # --- 1. 停掉旧版本 -------------------------------------------------------- Step '停止正在运行的服务' $was = @(Get-Process WeaselServer -ErrorAction SilentlyContinue) # WaitForExit 有返回值,不吞掉会被当成输出打到控制台,还把后面的 ✓ 挤到下一行 $was | ForEach-Object { try { $_.Kill(); [void]$_.WaitForExit(5000) } catch {} } if ($was.Count) { Start-Sleep -Milliseconds 800 } StepDone $(if ($was.Count) { '已停止旧进程' } else { '没有在运行' }) # --- 2. 下载(带进度条)--------------------------------------------------- Step '下载程序包' Write-Host '' $tmp = Join-Path $env:TEMP "swallow-$Version.zip" function Show-Progress([long]$done, [long]$len, [double]$secs) { $w = 28 $frac = if ($len -gt 0) { [double]$done / $len } else { 0 } $fill = [int][Math]::Round($frac * $w) $bar = ('█' * $fill) + ('░' * ($w - $fill)) $mb = '{0,5:N1}/{1:N1} MB' -f ($done / 1MB), ($len / 1MB) $rate = if ($secs -gt 0.3) { '{0,5:N1} MB/s' -f ($done / 1MB / $secs) } else { ' … ' } # \r 回到行首原地重画。用 [Console]::Write 而不是 Write-Host: # Write-Host 每次都会补换行,进度条会变成刷屏。 [Console]::Write("`r ") Write-Host $bar -NoNewline -ForegroundColor Cyan [Console]::Write((' {0,4:N0}% {1} {2}' -f ($frac * 100), $mb, $rate)) } $sw = [Diagnostics.Stopwatch]::StartNew() try { $req = [Net.HttpWebRequest]::Create($PayloadUrl) $req.UserAgent = "swallow-installer/$Version" $resp = $req.GetResponse() $len = $resp.ContentLength $in = $resp.GetResponseStream() $out = [IO.File]::Create($tmp) $buf = New-Object byte[] 262144 $done = 0L $lastDraw = -1000 while (($n = $in.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n) $done += $n # 限流重画:每收到 256KB 就画一次的话,画进度条本身成了瓶颈。 if ($sw.ElapsedMilliseconds - $lastDraw -ge 120) { Show-Progress $done $len $sw.Elapsed.TotalSeconds $lastDraw = $sw.ElapsedMilliseconds } } Show-Progress $done $len $sw.Elapsed.TotalSeconds Write-Host '' } catch { Write-Host '' Die "下载失败:$($_.Exception.Message)" } finally { if ($out) { $out.Close() } if ($in) { $in.Close() } if ($resp){ $resp.Close()} } Write-Host (' [{0}/{1}] ' -f $script:StepNo, $StepTotal) -NoNewline -ForegroundColor DarkGray Write-Host '下载完成'.PadRight(34) -NoNewline -ForegroundColor Gray StepDone ('{0:N1} MB' -f ((Get-Item $tmp).Length / 1MB)) # --- 3. 校验 -------------------------------------------------------------- Step '校验完整性' $got = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() if ($got -ne $PayloadSha) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue Die "SHA256 不符,文件可能损坏或被篡改,已删除。`n 期望 $PayloadSha`n 实际 $got" } StepDone 'SHA256 一致' # --- 4. 铺文件 ------------------------------------------------------------ Step '解压程序文件' if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($tmp) $count = 0 try { # 逐条覆盖而不是先删目录:用户可能把自己的词库放在了 data 下面。 foreach ($e in $zip.Entries) { $dest = Join-Path $InstallDir $e.FullName if ([string]::IsNullOrEmpty($e.Name)) { New-Item -ItemType Directory -Path $dest -Force | Out-Null continue } $dir = Split-Path $dest -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } try { [IO.Compression.ZipFileExtensions]::ExtractToFile($e, $dest, $true) } catch { # 占用中的 DLL 覆盖不掉:改名让路,新文件照样就位。这正是「不用重启 # 电脑」的由来 —— 旧的那份留给下次开机清理。 $old = "$dest.old." + [Guid]::NewGuid().ToString('N').Substring(0, 6) try { Move-Item $dest $old -Force [IO.Compression.ZipFileExtensions]::ExtractToFile($e, $dest, $true) } catch { Die "无法写入 $dest:$($_.Exception.Message)" } } $count++ } } finally { $zip.Dispose() } Remove-Item $tmp -Force -ErrorAction SilentlyContinue StepDone "$count 个文件" # --- 5. 注册文字服务 ------------------------------------------------------ Step '注册输入法' New-Item -Path 'HKLM:\SOFTWARE\Rime\Swallow' -Force | Out-Null Set-ItemProperty -Path 'HKLM:\SOFTWARE\Rime\Swallow' -Name 'InstallDir' -Value $InstallDir $setup = Start-Process -FilePath (Join-Path $InstallDir 'WeaselSetup.exe') ` -ArgumentList '/s' -Wait -PassThru -WindowStyle Hidden if ($setup.ExitCode -ne 0) { Die "WeaselSetup 返回 $($setup.ExitCode),注册失败。" } StepDone 'TSF 已注册' # --- 6. 部署配置 ---------------------------------------------------------- Step '部署输入方案' $userDir = Join-Path $env:APPDATA 'Rime' if (-not (Test-Path $userDir)) { New-Item -ItemType Directory -Path $userDir -Force | Out-Null } # librime-predict 只在用户目录找 predict.db,不回退共享目录。不放这一份, # 顺序联想会静默失效。已存在就不动 —— 用户可能换过版本。 $predictSrc = Join-Path $InstallDir 'data\predict.db' $predictDst = Join-Path $userDir 'predict.db' if ((Test-Path $predictSrc) -and -not (Test-Path $predictDst)) { Copy-Item $predictSrc $predictDst } # 先起服务再部署:UpdateWorkspace 要跟服务端通信才能进维护模式。 Start-Process -FilePath (Join-Path $InstallDir 'WeaselServer.exe') -WindowStyle Hidden | Out-Null Start-Sleep -Seconds 2 $dep = Start-Process -FilePath (Join-Path $InstallDir 'WeaselDeployer.exe') ` -ArgumentList '/deploy' -Wait -PassThru -WindowStyle Hidden if ($dep.ExitCode -ne 0) { StepWarn "部署返回 $($dep.ExitCode),输入法仍可用,可在托盘菜单里「重新部署」" } else { StepDone '词库已编译' } # --- 7. 自启与卸载入口 ---------------------------------------------------- Step '登记自启与卸载' Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' ` -Name 'WeaselServer' -Value (Join-Path $InstallDir 'WeaselServer.exe') # 本地留一份卸载脚本:服务器哪天没了、或者断网,也得能卸干净。 $uninstallPs1 = Join-Path $InstallDir 'uninstall.ps1' try { Invoke-WebRequest -Uri "$Base/uninstall" -OutFile $uninstallPs1 -UseBasicParsing } catch {} $unkey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Swallow' New-Item -Path $unkey -Force | Out-Null Set-ItemProperty -Path $unkey -Name 'DisplayName' -Value '小飞燕输入法' Set-ItemProperty -Path $unkey -Name 'DisplayVersion' -Value $Version Set-ItemProperty -Path $unkey -Name 'DisplayIcon' -Value (Join-Path $InstallDir 'WeaselServer.exe') Set-ItemProperty -Path $unkey -Name 'InstallLocation' -Value $InstallDir Set-ItemProperty -Path $unkey -Name 'Publisher' -Value '小飞燕' Set-ItemProperty -Path $unkey -Name 'UninstallString' ` -Value "powershell -NoProfile -ExecutionPolicy Bypass -File `"$uninstallPs1`"" Set-ItemProperty -Path $unkey -Name 'NoModify' -Value 1 -Type DWord Set-ItemProperty -Path $unkey -Name 'NoRepair' -Value 1 -Type DWord StepDone '完成' # --- 收尾 ----------------------------------------------------------------- Write-Host '' Write-Host ' ✓ 装好了,不需要重启电脑。' -ForegroundColor Green Write-Host '' $rows = @( @('切换输入法', 'Win + 空格,选「小飞燕」'), @('三色分组', '打字时按 ~ 开启,再按 1 / 2 / 3 选组'), @('面板', 'Ctrl + ; 联想词 / 剪贴板 / 预设值'), @('设置', '右键托盘里的小飞燕图标'), @('卸载', "irm $Base/uninstall | iex") ) foreach ($r in $rows) { Write-Host ' ' -NoNewline Write-Host $r[0].PadRight(12) -NoNewline -ForegroundColor White Write-Host $r[1] -ForegroundColor Gray } Write-Host '' Write-Host ' 安装时已经开着的程序还握着旧的一份,重开一次才会用上新版。' -ForegroundColor DarkGray Write-Host ''