黄老师

我用Claude将软件绿色化的经验
这半个多月,我用 Claude 把 Claude Desktop 和 DeepSeek Harness 都做成了绿...
扫描右侧二维码阅读全文
09
2026/11

我用Claude将软件绿色化的经验

这半个多月,我用 Claude 把 Claude Desktop 和 DeepSeek Harness 都做成了绿色版,放在 D:Green 下面,程序和数据都在 D 盘。

10 月 22 日先做 Claude Desktop。起点是 Gemini 生成的一个安装脚本,我让 Claude 检查,结果发现好几个直接出错的地方:自动更新因为一处语法错误永远完成不了,生成的 MCP 配置不是合法的 JSON,解压出来的部分文件名没有还原(%40 应该是 @)。脚本原来把整个用户目录都改到了 Data 里,结果 git、ssh 找不到我本机的配置,Claude 打开浏览器时还在 Data 里冒出一份空白的 Chrome 配置。最后改成只给 Claude 指定它自己的数据位置,别的都不动。

之后又陆续解决了几件事:每次重装都要重新下载几百兆,就加了安装包缓存,只在有新版本时才下载;新版本发布那天,下载两次都在中途断开,就改成断点续传,并且先校验数字签名再动现有程序;运行更新时窗口里多出一行“找不到路径”,查下来是 cmd 处理中文的老毛病,于是 bat 文件里一个中文字都不放。我的电脑重启后 C 盘会还原,这正好和绿色版互补,偶尔写到 C 盘的东西重启就没了。

10 月 29 日照着同样的思路做了 DeepSeek Harness。它的安装包是另一种格式,里面嵌着一个 7z 压缩包,用 Windows 自带的 tar 就能解开;数据位置靠 DSH_HOME 这个环境变量指定;它自带的自动更新会把程序装进 C 盘,所以在便携版里关掉了,更新只走脚本。

整个过程里,我反复让 Claude 检查脚本有没有 bug、冗余代码和不合理的注释,要求脚本看起来像一次写成的,不留改来改去的痕迹。几点体会:

  • 先查清软件把数据放在哪里,只管它自己的数据。
  • 安装包先校验,再改动现有程序。
  • 测试放在单独的目录里做,还要在真实的命令行窗口里跑,有些问题只在那里出现。

11 月 7 日,我把两套脚本的共同做法整理成了一篇文章《桌面应用绿色便携化的共性设计》:


桌面应用绿色便携化的共性设计

2026-11-07

Claude Desktop 与 DeepSeek Harness 的便携化脚本用的是同一套设计:一个 Setup 脚本生成启动器和更新脚本,程序放 App、数据放 Data;只在版本变化时下载,安装包校验通过后才改动现有程序。

目录与文件:一个脚本生成全部

Setup 脚本是唯一需要维护的文件。启动器、更新入口和更新脚本都由它原样写出,修改只改 Setup,重新运行一次就全部同步。

位置内容由谁写入
<名称>PortableSetup.bat安装向导,内含其余三个文件的全文手工维护
<名称>Portable.bat启动器:设置数据位置后启动程序Setup 生成
<名称>PortableUpdate.bat更新入口,只调用更新脚本Setup 生成
<名称>PortableUpdate.ps1查询、下载、校验、部署的全部逻辑Setup 生成
App\程序文件,每次部署整体替换更新脚本
Data\应用自己的数据(桌面版数据与运行时目录)应用
Data\Packages\当前版本的安装包缓存更新脚本

Setup 只做四件事:选择部署方式([1] 重新部署、[2] 彻底重置、[3] 退出),写出三个文件,调用更新脚本(-Reinstall 或 -ResetData),生成两个桌面快捷方式。下载与部署的代码只在更新脚本里存在一份,首次安装和日常更新走的是同一段代码。

数据隔离:只管应用自己的数据

启动器只为应用指定它自己的数据位置,不改 USERPROFILE、HOME、APPDATA、LOCALAPPDATA、TEMP。改这些系统变量会波及应用调用的所有程序:git 和 ssh 找不到本机配置,应用打开浏览器时还会在便携目录里新建一份空白浏览器配置。

桌面版部分统一用 Electron 的 --user-data-dir 参数,指向 Data\UserData。运行时部分各有一个应用专用的环境变量:

应用环境变量默认位置便携版指向
Claude DesktopCLAUDE_CONFIG_DIR~\.claude 与 ~\.claude.jsonData\.claude
DeepSeek HarnessDSH_HOME~\.dshData\.dsh
DeepSeek HarnessDSH_AGENTS_HOME~\.agentsData\.agents

这些变量及其默认位置,是在程序的 app.asar 里搜索 homedir()、process.env. 和 getPath("userData") 找到的。启动器用 setlocal,变量不会泄漏到调用它的命令行窗口。

版本与安装包缓存:只在版本变化时下载

更新脚本先查到线上最新版本号,这一步不下载安装包;本地已是该版本就直接结束,整个检查约 1 秒。

项目Claude DesktopDeepSeek Harness
版本来源请求官方下载地址但不跟随跳转,从跳转目标里读出版本号程序自带更新器所用的官方清单 nightly.yml,含版本号、下载地址与 SHA-512
版本格式3.6556.20.2.1-rc.3
本地版本Claude.exe 的 ProductVersionDeepSeek Harness.exe 的 FileVersion
已是最新的判断本地版本 ≥ 线上版本两者字符串相同(预发布号不便比大小)

安装包缓存的规则两者相同:

  • 保存为 Data\Packages\<名称>-<版本>.<扩展名>,已有目标版本时直接使用。
  • 新包校验通过后,删除其他旧包,只保留最新一个。
  • 彻底重置([2])清空 Data 时保留 Packages。
  • 查询失败(如断网)时改用已缓存的安装包,网络不通也能重新部署。

可靠下载:先校验,后改动

下载和校验都在改动现有程序之前完成,任何一步失败,App 和数据都保持原样。

  • 地址带版本号:latest 一类链接的内容会随版本变,续传时可能把两个版本的数据拼在一起。Claude 的跳转目标、DeepSeek 清单里的地址都带版本号。
  • 断点续传:用 HttpWebRequest 加 Range 头。服务器返回 206 就追加,返回 200 就从头写,返回 416 表示已下完。Windows PowerShell 5.1 的 Invoke-WebRequest 不支持续传,所以不用。
  • 重试与超时:一次运行最多尝试 5 次,间隔 3 秒;连接和读取各 30 秒超时,版本查询 15 秒。
  • .part 文件:先写入 .part,大小与服务器一致后才改为正式文件名;运行中断留下的 .part,下次接着下。
  • 数字签名:必须有效,且签名者必须是发布方(Anthropic, PBC、Hangzhou DeepSeek Artificial Intelligence Co., Ltd.)。缓存的安装包每次使用前也要校验。
  • 哈希:清单给出 SHA-512 时(DeepSeek),再比对哈希;离线使用缓存时只校验签名。
  • 失败处理:校验不过就删掉这个安装包并报错,旧安装包和现有程序都不动;重新运行即可重新下载。
  • 先改名后校验:Windows 的签名检查看扩展名,MSIX 只有叫 .msix 时才能校验,所以 .part 先改名。

实测中两次把下载强行中断在 61.8MB 和 73.2MB,再次运行都从断点下完,文件大小与服务器完全一致。把缓存包中间改动 2 个字节,签名检查就返回 HashMismatch。

不运行安装器,直接取出程序

两款软件都不运行官方安装程序:运行它会把程序装进系统目录、写注册表。脚本只从安装包里取出程序文件,方法取决于安装包格式,不需要另装 7-Zip 等工具。

项目Claude DesktopDeepSeek Harness
安装包格式MSIX(本质是 zip)NSIS 安装程序,内嵌 7z 压缩包
取出方法.NET 的 ZipFile 读取 app/ 下的条目在文件中找到 7z 文件头,切出压缩包,用系统自带的 tar.exe 解压
要注意的坑条目名是 URL 编码,%40 要还原为 @,否则原生模块加载不了必须用 System32\tar.exe 的完整路径,Git 自带的 GNU tar 不认 7z
解压前的确认先打开 zip 确认可读先用 tar -tf 列一遍确认可读

7z 压缩包的长度可以从文件头算出:文件头共 32 字节,第 12–19 字节是尾部索引偏移,第 20–27 字节是索引长度,总长 = 32 + 偏移 + 长度。就是靠这个算法把它从安装程序里切出来的。

部署流程:固定的十步

更新脚本每次都按同一顺序执行,前六步只读不改,第 7 步起才动现有程序。

  1. 读取本地已安装的版本。
  2. 查询线上最新版本;查不到就用已缓存的安装包,都没有就报错。
  3. 已是最新、且没有要求重新部署时,直接结束。
  4. 准备安装包:有缓存用缓存,否则断点续传下载。
  5. 校验签名(和哈希),通过后删除旧安装包。
  6. 确认安装包里的程序文件可以读取。
  7. 结束便携目录下运行的所有进程,并等它们退出;仍有剩余就列出路径并停止。
  8. 选了彻底重置时,清空 Data(保留 Packages)。
  9. 删掉整个 App 再解压,不留旧版本的残留文件。
  10. 关闭程序自带的更新(DeepSeek 删除 app-update.yml),更新只走脚本。

第 7 步按程序路径选进程,不按进程名。按名字会误杀别处的同名程序(另装的 Claude、终端里的 Claude Code 也叫 claude.exe),还会漏掉 App 里的辅助程序和内置的 Node.js、Python。DeepSeek Harness 的后台在退出时会再启动子进程,所以它的脚本反复结束,直到没有剩余或超过 15 秒。

编码与 cmd 的坑

bat 文件只用 ASCII 字符,所有中文提示都由 PowerShell 输出。这条规则来自一个实际碰到的 cmd 缺陷:用 chcp 65001 切到 UTF-8 后,含中文的行会让 cmd 算错读取位置,把下一行的片段当命令执行,窗口里多出一行“The system cannot find the path specified.”。只在真实的控制台窗口里出现,输出被重定向时不会出现。

文件编码原因
.batUTF-8 无 BOM,只含 ASCII带 BOM 时 cmd 识别不了第一行;含中文会触发上述缺陷
.ps1UTF-8 带 BOM无 BOM 时 Windows PowerShell 5.1 按 ANSI 读取中文
全部生成文件CRLF 换行bat 用 LF 换行容易解析出错

Setup 本身是“bat 外壳 + PowerShell 正文”的单文件:bat 部分只有几行,读取自身、找到 :::POWERSHELL_BEGIN::: 标记,把其后的内容交给 PowerShell 执行。生成的三个文件以单引号 here-string 原样写在 Setup 里,不需要处理转义。

移植清单:换一个软件要查清的事

换一个软件,脚本骨架不变,只需查清下表各项并替换。全部可以在不运行安装程序的前提下查到。

事项怎么查Claude DesktopDeepSeek Harness
版本与下载地址看下载地址是否跳转;看程序里的 resources\app-update.yml下载地址的跳转目标app-update.yml 指向的 nightly.yml
安装包格式在文件里搜 Nullsoft、zip 与 7z 的文件头MSIXNSIS 内嵌 7z
发布者签名Get-AuthenticodeSignatureAnthropic, PBCHangzhou DeepSeek Artificial Intelligence Co., Ltd.
主程序名解压后看根目录Claude.exeDeepSeek Harness.exe(含空格)
本地版本字段主程序的 VersionInfoProductVersionFileVersion
运行时数据变量在 app.asar 里搜 homedir()、process.env.CLAUDE_CONFIG_DIRDSH_HOME、DSH_AGENTS_HOME
自带更新器搜 autoUpdater、app-update.yml便携运行时未发现有,靠 app-update.yml 开关
链接协议搜 setAsDefaultProtocolClientclaude://dsh://

共同的局限

两款软件的便携版有同样的几个局限,多数来自程序本身或“不改系统目录”的取舍。

局限原因影响与对策
浏览器链接绕过启动器程序每次启动都把 claude://、dsh:// 登记为直接运行主程序,不带便携参数,脚本改了也会被覆盖点链接会启动一个非便携副本,数据写到 C 盘;关掉它,从便携快捷方式打开
少量缓存写到 C 盘不重定向 LOCALAPPDATA 和 TEMP如 claude-cli-nodejs、node-addon-native-custom-loader、显卡缓存,均不到 1MB
登录状态绑定本机Electron 用 Windows 的 DPAPI 加密 Cookie 等登录数据整个文件夹拷到别的电脑,记录和设置都在,但 Claude 需要重新登录(DeepSeek 未实测)
C 盘重启还原的电脑DPAPI 主密钥存在 C 盘,还原点里的密钥会到期(本机是 2026-12-08)到期后避免选 [2] 彻底重置,否则每次重启都要重新登录;确需重置时先更新还原点
安装目录写死Setup 与更新脚本里写的是固定路径,启动器用的是相对路径搬动文件夹后要改脚本里的路径再运行一次 Setup
启动时闪黑窗快捷方式指向 bat 文件仅影响观感
更新后不自动重启脚本只负责部署更新完成后从快捷方式重新打开

测试方法

测试一律在沙盒目录里进行:把脚本里的安装目录替换成沙盒路径再运行,不碰正在使用的安装。沙盒路径故意带空格,和 D:\Green\DeepSeek Harness 一样。

测试做法能发现的问题
安装、重新部署、彻底重置在沙盒里各运行一遍流程、生成文件、快捷方式是否正确
下载中断下载中途强行结束进程,再运行一次续传是否正确、文件大小是否一致
安装包损坏改动缓存包中间的几个字节校验能否拦下,现有程序是否不受影响
断网把版本查询地址改成不存在的域名是否改用缓存的安装包
真实控制台通过快捷方式在新窗口运行,结束前把窗口内容读出存成文件cmd 的 UTF-8 缺陷只在这里出现
运行中更新先启动程序,再运行重新部署,连续 3 轮进程能否全部结束
实际启动启动沙盒里的程序,检查 Data 和 C 盘的相关目录数据是否全部写进 Data、自带更新是否已关闭

测试中程序写入的登记项(如指向沙盒的 dsh:// 协议)和 C 盘缓存,测试后要清理掉。


附:两个安装脚本

ClaudePortableSetup.bat

@echo off
:: Runs the PowerShell section below. Every batch line must stay ASCII-only (cmd misparses Chinese text).
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
    "$lines = [System.IO.File]::ReadAllLines('%~f0', [System.Text.Encoding]::UTF8);" ^
    "$start = [Array]::IndexOf($lines, ':::POWERSHELL_BEGIN:::') + 1;" ^
    "Invoke-Expression ($lines[$start..($lines.Length - 1)] -join [Environment]::NewLine)"
pause
exit /b

:::POWERSHELL_BEGIN:::
$ErrorActionPreference = "Stop"
$Target = "D:\Green\Claude"
$TargetApp = Join-Path $Target "App"
$UpdatePs1 = Join-Path $Target "ClaudePortableUpdate.ps1"

# 生成文件的编码:
#   .bat  无 BOM(否则 cmd 无法识别首行),且只含 ASCII 字符,中文提示一律由 PowerShell 输出
#   .ps1  带 BOM(否则 Windows PowerShell 5.1 会按 ANSI 编码读取中文)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$utf8WithBom = New-Object System.Text.UTF8Encoding($true)

# 以 Windows 换行符写出生成的文件
function Save-Text($Name, $Text, $Encoding) {
    [System.IO.File]::WriteAllText((Join-Path $Target $Name), (($Text -replace "`r?`n", "`r`n") + "`r`n"), $Encoding)
}

Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "         Claude Desktop 绿色便携化自动部署向导         " -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan

# 部署方式:默认重新部署;已安装时可改选彻底重置或退出(下载与部署由 ClaudePortableUpdate.ps1 完成)
$deployArgs = @{ Reinstall = $true }
if (Test-Path "$TargetApp\Claude.exe") {
    Write-Host "`n[提示] 检测到已存在安装版本。" -ForegroundColor Yellow
    Write-Host "  [1] 重新部署应用核心(保留聊天记录、MCP 与个人配置)"
    Write-Host "  [2] 彻底全新重置(清空所有数据,仅保留已下载的安装包)"
    Write-Host "  [3] 退出"

    $choice = ""
    while ($choice -notmatch '^[123]$') { $choice = (Read-Host "`n请选择操作 [1, 2, 3]").Trim() }
    if ($choice -eq "3") { Write-Host "已取消操作。" -ForegroundColor Gray; return }
    if ($choice -eq "2") { $deployArgs.ResetData = $true }
}

# 1. 写入启动器与更新组件
Write-Host "`n[1/3] 写入启动器与更新组件..." -ForegroundColor Cyan

# 1.1 启动器:只为 Claude 指定数据位置
#   --user-data-dir     桌面版数据(聊天、登录、claude_desktop_config.json 等)
#   CLAUDE_CONFIG_DIR   Claude Code 数据(.claude 目录及 .claude.json)
$launcherText = @'
@echo off
setlocal
set "PORTABLE_DIR=%~dp0"
set "CLAUDE_CONFIG_DIR=%PORTABLE_DIR%Data\.claude"
if not exist "%PORTABLE_DIR%Data\UserData" md "%PORTABLE_DIR%Data\UserData"
if not exist "%CLAUDE_CONFIG_DIR%" md "%CLAUDE_CONFIG_DIR%"
start "" "%PORTABLE_DIR%App\Claude.exe" --user-data-dir="%PORTABLE_DIR%Data\UserData" %*
exit /b
'@
Save-Text "ClaudePortable.bat" $launcherText $utf8NoBom

# 1.2 更新入口
$updateBatText = @'
@echo off
title Claude Portable Update
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ClaudePortableUpdate.ps1"
pause
'@
Save-Text "ClaudePortableUpdate.bat" $updateBatText $utf8NoBom

# 1.3 安装/更新脚本
$updatePsText = @'
# Claude Desktop 便携版安装/更新脚本(由 ClaudePortableSetup.bat 生成)
#   无参数       有新版本时更新
#   -Reinstall   即使已是最新也重新部署
#   -ResetData   清空 Data(保留已下载的安装包)后重新部署
# 安装包保存为 Data\Packages\Claude-<版本>.msix:已有目标版本时直接使用,否则断点续传下载;
# 使用前校验 Anthropic 数字签名,且只保留最新的一个。
param([switch]$Reinstall, [switch]$ResetData)
$ErrorActionPreference = "Stop"
$TargetRoot = "D:\Green\Claude"
$TargetApp = Join-Path $TargetRoot "App"
$DataDir = Join-Path $TargetRoot "Data"
$PackageDir = Join-Path $DataDir "Packages"
$LocalExe = Join-Path $TargetApp "Claude.exe"
$RedirectUrl = "https://claude.ai/api/desktop/win32/x64/msix/latest/redirect"

# 1. 本地已安装版本
$localVer = $null
if ((Test-Path $LocalExe) -and ((Get-Item $LocalExe).VersionInfo.ProductVersion -match '(\d+(\.\d+)+)')) {
    $localVer = [version]$matches[1]
}

# 2. 查询线上最新版本(只读取下载跳转地址,从中解析版本号)
Write-Host "[*] 正在查询官方最新版本信息..." -ForegroundColor Cyan
$downloadUrl = $null
try {
    $req = [System.Net.HttpWebRequest]::Create($RedirectUrl)
    $req.UserAgent = "Mozilla/5.0"
    $req.AllowAutoRedirect = $false
    $req.Timeout = 15000
    $res = $req.GetResponse()
    $downloadUrl = $res.Headers["Location"]
    $res.Close()
} catch { }
$remoteVer = $null
if ($downloadUrl -match '/(\d+(\.\d+){2,3})/') { $remoteVer = [version]$matches[1] }

# 3. 确定目标版本:优先线上最新版,查询失败时使用已下载的安装包
$cached = Get-ChildItem $PackageDir -Filter "Claude-*.msix" -ErrorAction SilentlyContinue |
    Where-Object { $_.BaseName -match '^Claude-\d+(\.\d+)+$' } |
    Sort-Object { [version]$_.BaseName.Substring(7) } -Descending |
    Select-Object -First 1
if ($remoteVer) {
    $targetVer = $remoteVer
} elseif ($cached) {
    $targetVer = [version]$cached.BaseName.Substring(7)
    Write-Host "[!] 未能获取线上版本,改用已下载的安装包。" -ForegroundColor Yellow
} else {
    throw "未能获取线上版本,且没有已下载的安装包,请检查网络后重试。"
}
$package = Join-Path $PackageDir "Claude-$targetVer.msix"

Write-Host "本地版本: $(if ($localVer) { $localVer } else { '未安装' })" -ForegroundColor Yellow
Write-Host "目标版本: $targetVer" -ForegroundColor Yellow

if (-not ($Reinstall -or $ResetData) -and $localVer -and $localVer -ge $targetVer) {
    Write-Host "`n[OK] 当前已是最新版本,无需更新!" -ForegroundColor Green
    return
}

# 4. 准备安装包:已下载过则直接使用;否则以断点续传方式下载到 .part 文件(中断后从断点继续,最多 5 次),完整后再改名
if (Test-Path $package) {
    Write-Host "`n[*] 使用已下载的安装包: $package" -ForegroundColor Cyan
} else {
    Write-Host "`n[*] 正在下载 Claude $targetVer 安装包..." -ForegroundColor Cyan
    New-Item -ItemType Directory -Path $PackageDir -Force | Out-Null
    $part = "$package.part"
    for ($attempt = 1; ; $attempt++) {
        $done = if (Test-Path $part) { (Get-Item $part).Length } else { 0 }
        try {
            $req = [System.Net.HttpWebRequest]::Create($downloadUrl)
            $req.UserAgent = "Mozilla/5.0"
            $req.Timeout = 30000
            $req.ReadWriteTimeout = 30000
            if ($done) { $req.AddRange([long]$done) }
            $res = $req.GetResponse()
            try {
                if ($res.StatusCode -ne [System.Net.HttpStatusCode]::PartialContent) { $done = 0 }
                $total = if ($res.ContentLength -ge 0) { $done + $res.ContentLength } else { -1 }
                $out = New-Object System.IO.FileStream($part, $(if ($done) { "Append" } else { "Create" }), "Write")
                try { $res.GetResponseStream().CopyTo($out) } finally { $out.Dispose() }
            } finally {
                $res.Close()
            }
            if ($total -lt 0 -or (Get-Item $part).Length -eq $total) { break }
            throw "连接提前关闭"
        } catch {
            $http = $_.Exception.GetBaseException().Response
            if ($http -and [int]$http.StatusCode -eq 416) { break }   # 416:.part 已是完整文件
            if ($attempt -ge 5) { throw }
            Write-Host ("[!] 下载中断(已完成 {0:N0} MB),3 秒后续传(第 {1}/5 次)..." -f ((Get-Item $part -ErrorAction SilentlyContinue).Length / 1MB), ($attempt + 1)) -ForegroundColor Yellow
            Start-Sleep -Seconds 3
        }
    }
    Move-Item $part $package -Force
}

# 5. 校验数字签名(须为 Anthropic 签名且内容完整),通过后删除其他旧安装包,再改动现有程序
Write-Host "[*] 正在校验安装包..." -ForegroundColor Cyan
$signature = Get-AuthenticodeSignature $package
if ($signature.Status -ne "Valid" -or $signature.SignerCertificate.Subject -notlike 'CN="Anthropic, PBC"*') {
    Remove-Item $package -Force
    throw "安装包校验失败(文件不完整、已损坏或签名不符),已删除,请重新运行以重新下载。"
}
Get-ChildItem $PackageDir -Force | Where-Object { $_.FullName -ne $package } | Remove-Item -Recurse -Force
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($package)

try {
    # 6. 结束本便携目录下运行的所有进程(含 App 内的辅助程序),并等待退出
    Write-Host "[*] 正在终止运行中的 Claude 进程..." -ForegroundColor Yellow
    $inTarget = { $_.Path -and $_.Path.StartsWith("$TargetRoot\", [StringComparison]::OrdinalIgnoreCase) }
    $procs = @(Get-Process | Where-Object $inTarget)
    $procs | Stop-Process -Force -ErrorAction SilentlyContinue
    $procs | Wait-Process -Timeout 15 -ErrorAction SilentlyContinue
    $left = @(Get-Process | Where-Object $inTarget)
    if ($left.Count) { throw ("以下进程仍未退出,请手动关闭后重试: " + (($left | ForEach-Object { $_.Path } | Sort-Object -Unique) -join ", ")) }

    # 7. 清理并解压(MSIX 内的文件名为 URL 编码,如 %40 表示 @,需还原)
    if ($ResetData) {
        Write-Host "[*] 正在清空数据(保留已下载的安装包)..." -ForegroundColor Yellow
        Get-ChildItem $DataDir -Force | Where-Object { $_.FullName -ne $PackageDir } | Remove-Item -Recurse -Force
    }
    if (Test-Path $TargetApp) { Remove-Item $TargetApp -Recurse -Force }

    Write-Host "[*] 正在解压核心程序..." -ForegroundColor Cyan
    foreach ($entry in $zip.Entries) {
        if ($entry.FullName -like 'app/*' -and -not $entry.FullName.EndsWith('/')) {
            $dest = Join-Path $TargetApp ([Uri]::UnescapeDataString($entry.FullName.Substring(4)))
            [System.IO.Directory]::CreateDirectory((Split-Path $dest -Parent)) | Out-Null
            [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest)
        }
    }
} finally {
    $zip.Dispose()
}
Write-Host "`n[OK] Claude $targetVer 部署完成!" -ForegroundColor Green
'@
Save-Text "ClaudePortableUpdate.ps1" $updatePsText $utf8WithBom
Write-Host "[*] 启动器与更新组件写入完成。" -ForegroundColor Green

# 2. 部署核心程序
Write-Host "`n[2/3] 部署核心程序..." -ForegroundColor Cyan
& $UpdatePs1 @deployArgs

# 3. 生成桌面快捷方式
Write-Host "`n[3/3] 刷新桌面快捷方式..." -ForegroundColor Cyan
$ws = New-Object -ComObject WScript.Shell
$desktop = [Environment]::GetFolderPath('Desktop')
$shortcuts = [ordered]@{ "Claude Portable" = "ClaudePortable.bat"; "Claude Update" = "ClaudePortableUpdate.bat" }
foreach ($name in $shortcuts.Keys) {
    $lnk = $ws.CreateShortcut((Join-Path $desktop "$name.lnk"))
    $lnk.TargetPath = Join-Path $Target $shortcuts[$name]
    $lnk.WorkingDirectory = $Target
    $lnk.IconLocation = "$TargetApp\Claude.exe,0"
    $lnk.Save()
    Write-Host "[*] 已生成快捷方式: $name.lnk" -ForegroundColor Green
}

Write-Host "`n======================================================" -ForegroundColor Green
Write-Host "部署完成!全套绿色组件已就绪。" -ForegroundColor Green
Write-Host "======================================================" -ForegroundColor Green

DeepSeekHarnessPortableSetup.bat

@echo off
:: Runs the PowerShell section below. Every batch line must stay ASCII-only (cmd misparses Chinese text).
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
    "$lines = [System.IO.File]::ReadAllLines('%~f0', [System.Text.Encoding]::UTF8);" ^
    "$start = [Array]::IndexOf($lines, ':::POWERSHELL_BEGIN:::') + 1;" ^
    "Invoke-Expression ($lines[$start..($lines.Length - 1)] -join [Environment]::NewLine)"
pause
exit /b

:::POWERSHELL_BEGIN:::
$ErrorActionPreference = "Stop"
$Target = "D:\Green\DeepSeek Harness"
$TargetApp = Join-Path $Target "App"
$UpdatePs1 = Join-Path $Target "DeepSeekHarnessPortableUpdate.ps1"

# 生成文件的编码:
#   .bat  无 BOM(否则 cmd 无法识别首行),且只含 ASCII 字符,中文提示一律由 PowerShell 输出
#   .ps1  带 BOM(否则 Windows PowerShell 5.1 会按 ANSI 编码读取中文)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$utf8WithBom = New-Object System.Text.UTF8Encoding($true)

# 以 Windows 换行符写出生成的文件
function Save-Text($Name, $Text, $Encoding) {
    [System.IO.File]::WriteAllText((Join-Path $Target $Name), (($Text -replace "`r?`n", "`r`n") + "`r`n"), $Encoding)
}

Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "     DeepSeek Harness 绿色便携化自动部署向导     " -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan

# 部署方式:默认重新部署;已安装时可改选彻底重置或退出(下载与部署由 DeepSeekHarnessPortableUpdate.ps1 完成)
$deployArgs = @{ Reinstall = $true }
if (Test-Path "$TargetApp\DeepSeek Harness.exe") {
    Write-Host "`n[提示] 检测到已存在安装版本。" -ForegroundColor Yellow
    Write-Host "  [1] 重新部署应用核心(保留会话记录与个人配置)"
    Write-Host "  [2] 彻底全新重置(清空所有数据,仅保留已下载的安装包)"
    Write-Host "  [3] 退出"

    $choice = ""
    while ($choice -notmatch '^[123]$') { $choice = (Read-Host "`n请选择操作 [1, 2, 3]").Trim() }
    if ($choice -eq "3") { Write-Host "已取消操作。" -ForegroundColor Gray; return }
    if ($choice -eq "2") { $deployArgs.ResetData = $true }
}

# 1. 写入启动器与更新组件
Write-Host "`n[1/3] 写入启动器与更新组件..." -ForegroundColor Cyan

# 1.1 启动器:只为 DeepSeek Harness 指定数据位置
#   --user-data-dir     桌面版数据(窗口状态、登录、日志等)
#   DSH_HOME            运行时主目录(配置、会话、桌面配置档,默认 ~/.dsh)
#   DSH_AGENTS_HOME     共享 agents 目录(AGENTS.md、技能等,默认 ~/.agents)
$launcherText = @'
@echo off
setlocal
set "PORTABLE_DIR=%~dp0"
set "DSH_HOME=%PORTABLE_DIR%Data\.dsh"
set "DSH_AGENTS_HOME=%PORTABLE_DIR%Data\.agents"
if not exist "%PORTABLE_DIR%Data\UserData" md "%PORTABLE_DIR%Data\UserData"
if not exist "%DSH_HOME%" md "%DSH_HOME%"
if not exist "%DSH_AGENTS_HOME%" md "%DSH_AGENTS_HOME%"
start "" "%PORTABLE_DIR%App\DeepSeek Harness.exe" --user-data-dir="%PORTABLE_DIR%Data\UserData" %*
exit /b
'@
Save-Text "DeepSeekHarnessPortable.bat" $launcherText $utf8NoBom

# 1.2 更新入口
$updateBatText = @'
@echo off
title DeepSeek Harness Portable Update
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0DeepSeekHarnessPortableUpdate.ps1"
pause
'@
Save-Text "DeepSeekHarnessPortableUpdate.bat" $updateBatText $utf8NoBom

# 1.3 安装/更新脚本
$updatePsText = @'
# DeepSeek Harness 便携版安装/更新脚本(由 DeepSeekHarnessPortableSetup.bat 生成)
#   无参数       有新版本时更新
#   -Reinstall   即使已是最新也重新部署
#   -ResetData   清空 Data(保留已下载的安装包)后重新部署
# 版本信息来自官方更新清单(含版本号、下载地址与 SHA-512)。
# 安装包保存为 Data\Packages\DeepSeekHarness-<版本>.exe:已有目标版本时直接使用,否则断点续传下载;
# 使用前校验 SHA-512 与 DeepSeek 数字签名,且只保留最新的一个。
# 程序文件取自安装包内嵌的 7z 压缩包;部署后删除 app-update.yml,关闭程序自带的更新(它会把程序安装到系统目录),更新统一由本脚本完成。
param([switch]$Reinstall, [switch]$ResetData)
$ErrorActionPreference = "Stop"
$TargetRoot = "D:\Green\DeepSeek Harness"
$TargetApp = Join-Path $TargetRoot "App"
$DataDir = Join-Path $TargetRoot "Data"
$PackageDir = Join-Path $DataDir "Packages"
$LocalExe = Join-Path $TargetApp "DeepSeek Harness.exe"
$FeedUrl = "https://download.deepseek.com/dsh-desk/feeds/win-x64/nightly.yml"
$Tar = Join-Path $env:SystemRoot "System32\tar.exe"

# 1. 本地已安装版本(如 0.2.1-rc.3)
$localVer = $null
if (Test-Path $LocalExe) { $localVer = (Get-Item $LocalExe).VersionInfo.FileVersion }

# 2. 读取官方更新清单
Write-Host "[*] 正在查询官方最新版本信息..." -ForegroundColor Cyan
$feed = $null
try {
    $req = [System.Net.HttpWebRequest]::Create($FeedUrl)
    $req.UserAgent = "Mozilla/5.0"
    $req.Timeout = 15000
    $res = $req.GetResponse()
    try { $feed = (New-Object System.IO.StreamReader($res.GetResponseStream())).ReadToEnd() } finally { $res.Close() }
} catch { }
# 读取清单中的顶层字段,兼容 "key: value" 与 "key: >-" 换行写法
function Get-FeedValue($Key) {
    if ($feed -match "(?m)^${Key}:[ \t]*(?:>-?[ \t]*\r?\n[ \t]+)?['""]?([^'""\s]+)") { return $matches[1] }
}
$remoteVer = Get-FeedValue "version"
$downloadUrl = Get-FeedValue "path"
$remoteSha512 = Get-FeedValue "sha512"
if ($remoteVer -notmatch '^[0-9A-Za-z.+-]+$' -or -not $downloadUrl) { $remoteVer = $null }

# 3. 确定目标版本:优先线上最新版,查询失败时使用已下载的安装包
$cached = Get-ChildItem $PackageDir -Filter "DeepSeekHarness-*.exe" -ErrorAction SilentlyContinue |
    Sort-Object LastWriteTime -Descending |
    Select-Object -First 1
if ($remoteVer) {
    $targetVer = $remoteVer
} elseif ($cached) {
    $targetVer = $cached.BaseName.Substring(16)
    Write-Host "[!] 未能获取线上版本,改用已下载的安装包。" -ForegroundColor Yellow
} else {
    throw "未能获取线上版本,且没有已下载的安装包,请检查网络后重试。"
}
$package = Join-Path $PackageDir "DeepSeekHarness-$targetVer.exe"

Write-Host "本地版本: $(if ($localVer) { $localVer } else { '未安装' })" -ForegroundColor Yellow
Write-Host "目标版本: $targetVer" -ForegroundColor Yellow

# 预发布版本号(如 rc)不便比较大小,与目标版本一致即视为最新
if (-not ($Reinstall -or $ResetData) -and $localVer -eq $targetVer) {
    Write-Host "`n[OK] 当前已是最新版本,无需更新!" -ForegroundColor Green
    return
}

# 4. 准备安装包:已下载过则直接使用;否则以断点续传方式下载到 .part 文件(中断后从断点继续,最多 5 次),完整后再改名
if (Test-Path $package) {
    Write-Host "`n[*] 使用已下载的安装包: $package" -ForegroundColor Cyan
} else {
    Write-Host "`n[*] 正在下载 DeepSeek Harness $targetVer 安装包..." -ForegroundColor Cyan
    New-Item -ItemType Directory -Path $PackageDir -Force | Out-Null
    $part = "$package.part"
    for ($attempt = 1; ; $attempt++) {
        $done = if (Test-Path $part) { (Get-Item $part).Length } else { 0 }
        try {
            $req = [System.Net.HttpWebRequest]::Create($downloadUrl)
            $req.UserAgent = "Mozilla/5.0"
            $req.Timeout = 30000
            $req.ReadWriteTimeout = 30000
            if ($done) { $req.AddRange([long]$done) }
            $res = $req.GetResponse()
            try {
                if ($res.StatusCode -ne [System.Net.HttpStatusCode]::PartialContent) { $done = 0 }
                $total = if ($res.ContentLength -ge 0) { $done + $res.ContentLength } else { -1 }
                $out = New-Object System.IO.FileStream($part, $(if ($done) { "Append" } else { "Create" }), "Write")
                try { $res.GetResponseStream().CopyTo($out) } finally { $out.Dispose() }
            } finally {
                $res.Close()
            }
            if ($total -lt 0 -or (Get-Item $part).Length -eq $total) { break }
            throw "连接提前关闭"
        } catch {
            $http = $_.Exception.GetBaseException().Response
            if ($http -and [int]$http.StatusCode -eq 416) { break }   # 416:.part 已是完整文件
            if ($attempt -ge 5) { throw }
            Write-Host ("[!] 下载中断(已完成 {0:N0} MB),3 秒后续传(第 {1}/5 次)..." -f ((Get-Item $part -ErrorAction SilentlyContinue).Length / 1MB), ($attempt + 1)) -ForegroundColor Yellow
            Start-Sleep -Seconds 3
        }
    }
    Move-Item $part $package -Force
}

# 5. 校验 SHA-512(线上清单可用时)与数字签名(须为 DeepSeek 签名),通过后删除其他旧安装包,再改动现有程序
Write-Host "[*] 正在校验安装包..." -ForegroundColor Cyan
$hashOk = $true
if ($remoteSha512) {
    $stream = [System.IO.File]::OpenRead($package)
    try { $hashOk = [Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($stream)) -eq $remoteSha512 } finally { $stream.Dispose() }
}
$signature = Get-AuthenticodeSignature $package
if (-not $hashOk -or $signature.Status -ne "Valid" -or $signature.SignerCertificate.Subject -notlike 'CN="Hangzhou DeepSeek Artificial Intelligence Co., Ltd."*') {
    Remove-Item $package -Force
    throw "安装包校验失败(文件不完整、已损坏或签名不符),已删除,请重新运行以重新下载。"
}
Get-ChildItem $PackageDir -Force | Where-Object { $_.FullName -ne $package } | Remove-Item -Recurse -Force

# 6. 从安装包中取出内嵌的 7z 程序压缩包,并确认可以读取
#    7z 文件头:6 字节标识 + 版本(2) + CRC(4) + 尾部索引偏移(8) + 索引长度(8),压缩包总长 = 32 + 偏移 + 长度
$archive = "$package.7z"
try {
    $in = [System.IO.File]::OpenRead($package)
    try {
        $latin1 = [System.Text.Encoding]::GetEncoding(28591)
        $marker = $latin1.GetString([byte[]](0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C))
        $buffer = New-Object byte[] (8MB)
        $pos = 0L; $offset = -1L; $length = 0L
        while ($offset -lt 0 -and $pos -lt $in.Length) {
            $in.Position = $pos
            $read = $in.Read($buffer, 0, $buffer.Length)
            $hit = $latin1.GetString($buffer, 0, $read).IndexOf($marker, [StringComparison]::Ordinal)
            if ($hit -lt 0) { $pos += [Math]::Max(1, $read - 5); continue }
            $in.Position = $pos + $hit + 12
            $header = New-Object byte[] 16
            [void]$in.Read($header, 0, 16)
            $candidate = 32 + [BitConverter]::ToInt64($header, 0) + [BitConverter]::ToInt64($header, 8)
            if ($candidate -gt 32 -and $pos + $hit + $candidate -le $in.Length) { $offset = $pos + $hit; $length = $candidate }
            else { $pos += $hit + 1 }
        }
        if ($offset -lt 0) { throw "未在安装包中找到程序压缩包,安装包格式可能已变化。" }

        $out = [System.IO.File]::Create($archive)
        try {
            $in.Position = $offset
            $remaining = $length
            while ($remaining -gt 0) {
                $read = $in.Read($buffer, 0, [int][Math]::Min($buffer.Length, $remaining))
                $out.Write($buffer, 0, $read)
                $remaining -= $read
            }
        } finally {
            $out.Dispose()
        }
    } finally {
        $in.Dispose()
    }
    & $Tar -tf $archive | Out-Null
    if ($LASTEXITCODE) { throw "程序压缩包无法读取,请重新运行。" }

    # 7. 结束本便携目录下运行的所有进程(含内置的 Node.js、Python 等运行时),并等待退出
    #    后台进程在退出过程中可能再启动子进程,因此反复结束,直到没有剩余或超过 15 秒
    Write-Host "[*] 正在终止运行中的 DeepSeek Harness 进程..." -ForegroundColor Yellow
    $inTarget = { $_.Path -and $_.Path.StartsWith("$TargetRoot\", [StringComparison]::OrdinalIgnoreCase) }
    $deadline = (Get-Date).AddSeconds(15)
    do {
        $procs = @(Get-Process | Where-Object $inTarget)
        $procs | Stop-Process -Force -ErrorAction SilentlyContinue
        $procs | Wait-Process -Timeout 5 -ErrorAction SilentlyContinue
    } while ($procs.Count -and (Get-Date) -lt $deadline)
    $left = @(Get-Process | Where-Object $inTarget)
    if ($left.Count) { throw ("以下进程仍未退出,请手动关闭后重试: " + (($left | ForEach-Object { $_.Path } | Sort-Object -Unique) -join ", ")) }

    # 8. 清理并解压,然后关闭程序自带的更新
    if ($ResetData) {
        Write-Host "[*] 正在清空数据(保留已下载的安装包)..." -ForegroundColor Yellow
        Get-ChildItem $DataDir -Force | Where-Object { $_.FullName -ne $PackageDir } | Remove-Item -Recurse -Force
    }
    if (Test-Path $TargetApp) { Remove-Item $TargetApp -Recurse -Force }

    Write-Host "[*] 正在解压核心程序..." -ForegroundColor Cyan
    New-Item -ItemType Directory -Path $TargetApp -Force | Out-Null
    & $Tar -xf $archive -C $TargetApp
    if ($LASTEXITCODE) { throw "解压失败,请重新运行。" }
    $updateConfig = Join-Path $TargetApp "resources\app-update.yml"
    if (Test-Path $updateConfig) { Remove-Item $updateConfig -Force }
} finally {
    Remove-Item $archive -Force -ErrorAction SilentlyContinue
}
Write-Host "`n[OK] DeepSeek Harness $targetVer 部署完成!" -ForegroundColor Green
'@
Save-Text "DeepSeekHarnessPortableUpdate.ps1" $updatePsText $utf8WithBom
Write-Host "[*] 启动器与更新组件写入完成。" -ForegroundColor Green

# 2. 部署核心程序
Write-Host "`n[2/3] 部署核心程序..." -ForegroundColor Cyan
& $UpdatePs1 @deployArgs

# 3. 生成桌面快捷方式
Write-Host "`n[3/3] 刷新桌面快捷方式..." -ForegroundColor Cyan
$ws = New-Object -ComObject WScript.Shell
$desktop = [Environment]::GetFolderPath('Desktop')
$shortcuts = [ordered]@{ "DeepSeek Harness Portable" = "DeepSeekHarnessPortable.bat"; "DeepSeek Harness Update" = "DeepSeekHarnessPortableUpdate.bat" }
foreach ($name in $shortcuts.Keys) {
    $lnk = $ws.CreateShortcut((Join-Path $desktop "$name.lnk"))
    $lnk.TargetPath = Join-Path $Target $shortcuts[$name]
    $lnk.WorkingDirectory = $Target
    $lnk.IconLocation = "$TargetApp\DeepSeek Harness.exe,0"
    $lnk.Save()
    Write-Host "[*] 已生成快捷方式: $name.lnk" -ForegroundColor Green
}

Write-Host "`n======================================================" -ForegroundColor Green
Write-Host "部署完成!全套绿色组件已就绪。" -ForegroundColor Green
Write-Host "======================================================" -ForegroundColor Green
Last modification:November 9th, 2026 at 11:45 am

Leave a Comment