diff --git a/app/package-lock.json b/app/package-lock.json
index ff7b52bc..bfd426ce 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -16,7 +16,7 @@
},
"../desktop-bridge": {
"name": "@wsl043/dsh-portable-desktop-bridge",
- "version": "0.2.4",
+ "version": "0.2.5",
"license": "MIT"
},
"node_modules/@anthropic-ai/sdk": {
diff --git a/desktop-bridge/package.json b/desktop-bridge/package.json
index ee994f85..25b9c676 100644
--- a/desktop-bridge/package.json
+++ b/desktop-bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "@wsl043/dsh-portable-desktop-bridge",
- "version": "0.2.4",
+ "version": "0.2.5",
"private": true,
"type": "module",
"main": "lib/index.js",
diff --git a/installer/windows/DSH-Portable.iss b/installer/windows/DSH-Portable.iss
index 5905918c..c4edc279 100644
--- a/installer/windows/DSH-Portable.iss
+++ b/installer/windows/DSH-Portable.iss
@@ -8,7 +8,7 @@
#error ProjectRoot is required
#endif
#ifndef AppVersion
- #define AppVersion "0.2.4"
+ #define AppVersion "0.2.5"
#endif
[Setup]
@@ -38,7 +38,7 @@ LanguageDetectionMethod=uilanguage
ShowLanguageDialog=auto
CloseApplications=no
RestartApplications=no
-VersionInfoVersion=0.2.4.65534
+VersionInfoVersion=0.2.5.65534
VersionInfoProductName=DSH-Portable
VersionInfoDescription=DSH-Portable offline self-extractor
VersionInfoCompany=WSL043
@@ -50,10 +50,10 @@ Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.i
[CustomMessages]
english.StartApp=Start DeepSeek-Herness
chinesesimplified.StartApp=启动 DeepSeek-Herness
-english.ExistingProcessStopFailed=The existing DeepSeek-Herness process could not be stopped.
-chinesesimplified.ExistingProcessStopFailed=无法停止正在运行的 DeepSeek-Herness。
-english.AppStillRunning=DeepSeek-Herness is still running. Stop it before updating.
-chinesesimplified.AppStillRunning=DeepSeek-Herness 仍在运行。请先退出程序再更新。
+english.ExistingInstallBlocked=DSH-Portable already exists in:%n%n%1%n%nThis offline package only supports clean installation into an empty folder. To update while keeping sessions, settings, plugins, and workspace, run DSH-Portable-windows-x64.exe instead.
+chinesesimplified.ExistingInstallBlocked=以下位置已经存在 DSH-Portable:%n%n%1%n%n离线完整包只支持安装到空目录。若要保留会话、设置、插件和工作区并升级,请改用 DSH-Portable-windows-x64.exe。
+english.NonEmptyDirectoryBlocked=The target folder is not empty:%n%n%1%n%nInstallation was stopped to avoid mixing an old profile/runtime with the new package. Choose a new empty folder, or use DSH-Portable-windows-x64.exe to repair/update an existing installation.
+chinesesimplified.NonEmptyDirectoryBlocked=目标文件夹不是空目录:%n%n%1%n%n为避免旧 profile/runtime 与新版本混用,安装已停止。请选择新的空目录;若要修复或升级现有安装,请改用 DSH-Portable-windows-x64.exe。
[Files]
Source: "{#Stage}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@@ -62,19 +62,41 @@ Source: "{#Stage}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs crea
Filename: "{app}\DeepSeek-Herness.exe"; Description: "{cm:StartApp}"; Flags: nowait postinstall skipifsilent
[Code]
-function PrepareToInstall(var NeedsRestart: Boolean): String;
+function DirectoryHasEntries(const Directory: String): Boolean;
var
- ResultCode: Integer;
- StopEntry: String;
+ FindRec: TFindRec;
begin
- Result := '';
- StopEntry := ExpandConstant('{app}\DeepSeek-Herness.exe');
- if FileExists(StopEntry) then
+ Result := False;
+ if not DirExists(Directory) then
+ Exit;
+
+ if FindFirst(AddBackslash(Directory) + '*', FindRec) then
begin
- if not Exec(StopEntry, 'stop --no-browser --json', ExpandConstant('{app}'), SW_HIDE,
- ewWaitUntilTerminated, ResultCode) then
- Result := ExpandConstant('{cm:ExistingProcessStopFailed}')
- else if ResultCode <> 0 then
- Result := ExpandConstant('{cm:AppStillRunning}');
+ try
+ repeat
+ if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
+ begin
+ Result := True;
+ Exit;
+ end;
+ until not FindNext(FindRec);
+ finally
+ FindClose(FindRec);
+ end;
end;
end;
+
+function PrepareToInstall(var NeedsRestart: Boolean): String;
+var
+ Target: String;
+begin
+ Result := '';
+ Target := ExpandConstant('{app}');
+ if not DirectoryHasEntries(Target) then
+ Exit;
+
+ if FileExists(AddBackslash(Target) + 'DeepSeek-Herness.exe') then
+ Result := FmtMessage(ExpandConstant('{cm:ExistingInstallBlocked}'), [Target])
+ else
+ Result := FmtMessage(ExpandConstant('{cm:NonEmptyDirectoryBlocked}'), [Target]);
+end;
diff --git a/installer/windows/DeepSeek-Herness.iss b/installer/windows/DeepSeek-Herness.iss
index 1a32ef59..c9612ef5 100644
--- a/installer/windows/DeepSeek-Herness.iss
+++ b/installer/windows/DeepSeek-Herness.iss
@@ -8,7 +8,7 @@
#error ProjectRoot is required
#endif
#ifndef AppVersion
- #define AppVersion "0.2.4"
+ #define AppVersion "0.2.5"
#endif
[Setup]
@@ -36,7 +36,7 @@ LanguageDetectionMethod=uilanguage
ShowLanguageDialog=auto
CloseApplications=no
RestartApplications=no
-VersionInfoVersion=0.2.4.65534
+VersionInfoVersion=0.2.5.65534
VersionInfoProductName=DeepSeek-Herness
VersionInfoDescription=DeepSeek-Herness installer
VersionInfoCompany=WSL043
diff --git a/launcher/linux/Cargo.lock b/launcher/linux/Cargo.lock
index 01b4bde0..c3e04c2c 100644
--- a/launcher/linux/Cargo.lock
+++ b/launcher/linux/Cargo.lock
@@ -516,7 +516,7 @@ dependencies = [
[[package]]
name = "deepseek-herness-linux"
-version = "0.2.4"
+version = "0.2.5"
dependencies = [
"rfd",
"semver",
diff --git a/launcher/linux/Cargo.toml b/launcher/linux/Cargo.toml
index 0260d7d6..f94e4b7f 100644
--- a/launcher/linux/Cargo.toml
+++ b/launcher/linux/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deepseek-herness-linux"
-version = "0.2.4"
+version = "0.2.5"
description = "Native Linux shell for DSH-Portable"
authors = ["DSH-Portable contributors"]
edition = "2021"
diff --git a/launcher/linux/package-lock.json b/launcher/linux/package-lock.json
index b41b9a41..c2aae577 100644
--- a/launcher/linux/package-lock.json
+++ b/launcher/linux/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "dsh-portable-linux-shell-build",
- "version": "0.2.4",
+ "version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-portable-linux-shell-build",
- "version": "0.2.4",
+ "version": "0.2.5",
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
}
diff --git a/launcher/linux/package.json b/launcher/linux/package.json
index ce7592b2..3ab89749 100644
--- a/launcher/linux/package.json
+++ b/launcher/linux/package.json
@@ -1,6 +1,6 @@
{
"name": "dsh-portable-linux-shell-build",
- "version": "0.2.4",
+ "version": "0.2.5",
"private": true,
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
diff --git a/launcher/linux/tauri.conf.json b/launcher/linux/tauri.conf.json
index 4ec59348..9eff3f08 100644
--- a/launcher/linux/tauri.conf.json
+++ b/launcher/linux/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "DeepSeek-Herness",
- "version": "0.2.4",
+ "version": "0.2.5",
"identifier": "io.github.wsl043.dsh-portable",
"build": {
"frontendDist": "ui"
diff --git a/launcher/macos/Info-installed.plist b/launcher/macos/Info-installed.plist
index dc7310c3..7c4fecd6 100644
--- a/launcher/macos/Info-installed.plist
+++ b/launcher/macos/Info-installed.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.2.4
+ 0.2.5
CFBundleVersion
- 2004999
+ 2005999
LSMinimumSystemVersion
13.0
NSHighResolutionCapable
diff --git a/launcher/macos/Info-stop-installed.plist b/launcher/macos/Info-stop-installed.plist
index e054c790..47fe2323 100644
--- a/launcher/macos/Info-stop-installed.plist
+++ b/launcher/macos/Info-stop-installed.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.2.4
+ 0.2.5
CFBundleVersion
- 2004999
+ 2005999
LSMinimumSystemVersion
13.0
NSHighResolutionCapable
diff --git a/launcher/macos/Info.plist b/launcher/macos/Info.plist
index ce20003e..5526442f 100644
--- a/launcher/macos/Info.plist
+++ b/launcher/macos/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.2.4
+ 0.2.5
CFBundleVersion
- 2004999
+ 2005999
LSMinimumSystemVersion
13.0
NSHighResolutionCapable
diff --git a/launcher/update-core.mjs b/launcher/update-core.mjs
index 58ce49a9..dfa592ba 100644
--- a/launcher/update-core.mjs
+++ b/launcher/update-core.mjs
@@ -424,6 +424,12 @@ export async function rollbackPendingAppUpdate(layout, { beforeRestore = async (
return { status: 'rolled-back', operationId: journal.operationId }
}
+export async function resetManagedProfileModuleFallback(layout) {
+ const fallback = path.join(layout.dshHome, 'profiles', 'node_modules')
+ await rm(fallback, { recursive: true, force: true })
+ return fallback
+}
+
export async function applyStagedAppUpdate({ layout, stagedRoot, healthCheck, beforeRollback = async () => {} }) {
if (await readJson(layout.updateJournal, null)) throw new Error('A prior update must be recovered before another update can start.')
const metadata = await readJson(path.join(stagedRoot, 'component.json'), null)
@@ -460,6 +466,7 @@ export async function applyStagedAppUpdate({ layout, stagedRoot, healthCheck, be
if (existsSync(rootFile)) await rename(rootFile, path.join(paths.backupLicenses, name))
await rename(path.join(stagedLicenses, name), rootFile)
}
+ await resetManagedProfileModuleFallback(layout)
await writeJournal(layout, { operationId, phase: 'testing', hadLicenses })
const healthy = await healthCheck(metadata)
diff --git a/launcher/windows/DSH-Bootstrap.cs b/launcher/windows/DSH-Bootstrap.cs
index db308b01..bb06824d 100644
--- a/launcher/windows/DSH-Bootstrap.cs
+++ b/launcher/windows/DSH-Bootstrap.cs
@@ -22,8 +22,8 @@
[assembly: System.Reflection.AssemblyCompany("WSL043")]
[assembly: System.Reflection.AssemblyProduct("DSH-Portable")]
[assembly: System.Reflection.AssemblyCopyright("Copyright © WSL043 2026")]
-[assembly: System.Reflection.AssemblyVersion("0.2.4.65534")]
-[assembly: System.Reflection.AssemblyFileVersion("0.2.4.65534")]
+[assembly: System.Reflection.AssemblyVersion("0.2.5.65534")]
+[assembly: System.Reflection.AssemblyFileVersion("0.2.5.65534")]
namespace DshPortableBootstrap
{
@@ -63,6 +63,13 @@ internal sealed class PortablePayload
public long Bytes { get; set; }
}
+ [DataContract]
+ internal sealed class InstalledComponents
+ {
+ [DataMember(Name = "portableVersion")]
+ public string PortableVersion { get; set; }
+ }
+
[DataContract]
internal sealed class BootstrapResult
{
@@ -138,6 +145,14 @@ internal sealed class BootstrapInstaller
private const int ErrorAlreadyExists = 183;
private static readonly IntPtr InvalidFindHandle = new IntPtr(-1);
+ private sealed class SemanticVersion
+ {
+ internal long Major;
+ internal long Minor;
+ internal long Patch;
+ internal string[] Prerelease;
+ }
+
private readonly BootstrapOptions options;
private readonly Action reportStatus;
private readonly Action reportProgress;
@@ -151,14 +166,52 @@ internal BootstrapInstaller(BootstrapOptions options, Action reportStatu
internal async Task ExecuteAsync(CancellationToken cancellationToken)
{
- if (IsCompletePortable(options.Destination) && !options.UpgradeExisting)
+ bool existingComplete = IsCompletePortable(options.Destination);
+ bool upgradeExisting = options.UpgradeExisting;
+ string installedVersion = existingComplete ? ReadInstalledPortableVersion(options.Destination) : null;
+ PortableManifest manifest = null;
+ PortablePayload payload = null;
+
+ if (existingComplete && !upgradeExisting)
{
- reportStatus("DSH-Portable 已就绪,正在启动…");
- LaunchIfRequested();
- return Result("ready", null, null);
+ if (String.IsNullOrWhiteSpace(installedVersion))
+ {
+ reportStatus("现有 DSH-Portable 缺少版本信息,正在直接启动…");
+ LaunchIfRequested();
+ return Result("ready", null, "Installed version metadata is unavailable; automatic update was skipped.");
+ }
+
+ try
+ {
+ ValidateRemoteUri(options.ManifestUrl, options.AllowHttp, "manifest");
+ ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
+ reportStatus("正在检查 DSH-Portable 更新…");
+ manifest = await DownloadManifestAsync(
+ options.ManifestUrl,
+ cancellationToken,
+ TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ payload = ValidateManifest(manifest);
+ ValidateRemoteUri(payload.Url, options.AllowHttp, "payload");
+ int compared = ComparePortableVersions(installedVersion, manifest.Version);
+ if (compared >= 0)
+ {
+ reportStatus("DSH-Portable 已是最新版,正在启动…");
+ LaunchIfRequested();
+ return Result("ready", installedVersion, null);
+ }
+ upgradeExisting = true;
+ reportStatus("发现新版本 " + manifest.Version + ",正在准备安全升级…");
+ }
+ catch (Exception error)
+ {
+ if (error is OperationCanceledException && cancellationToken.IsCancellationRequested) throw;
+ reportStatus("暂时无法检查更新,正在启动现有版本…");
+ LaunchIfRequested();
+ return Result("ready", installedVersion, FriendlyMessage(error));
+ }
}
- if (Directory.Exists(options.Destination) && !options.UpgradeExisting)
+ if (Directory.Exists(options.Destination) && !upgradeExisting)
throw new InvalidOperationException("目标目录已经存在但内容不完整。为避免覆盖数据,请删除该空目录或把下载器移到其他位置后重试。");
ValidateRemoteUri(options.ManifestUrl, options.AllowHttp, "manifest");
@@ -174,10 +227,13 @@ internal async Task ExecuteAsync(CancellationToken cancellation
try
{
- reportStatus("正在获取 DSH-Portable 版本信息…");
- PortableManifest manifest = await DownloadManifestAsync(options.ManifestUrl, cancellationToken).ConfigureAwait(false);
- PortablePayload payload = ValidateManifest(manifest);
- ValidateRemoteUri(payload.Url, options.AllowHttp, "payload");
+ if (manifest == null || payload == null)
+ {
+ reportStatus("正在获取 DSH-Portable 版本信息…");
+ manifest = await DownloadManifestAsync(options.ManifestUrl, cancellationToken).ConfigureAwait(false);
+ payload = ValidateManifest(manifest);
+ ValidateRemoteUri(payload.Url, options.AllowHttp, "payload");
+ }
reportStatus("正在下载运行环境,完成后可离线使用…");
await DownloadFileAsync(payload.Url, temporaryArchive, payload.Bytes, cancellationToken).ConfigureAwait(false);
@@ -193,12 +249,14 @@ internal async Task ExecuteAsync(CancellationToken cancellation
string extracted = Path.Combine(stagingRoot, "DSH-Portable");
if (!IsCompletePortable(extracted))
throw new InvalidDataException("下载包缺少启动器或运行环境;没有修改目标目录。");
- if (options.UpgradeExisting)
+ if (upgradeExisting)
{
if (!IsCompletePortable(options.Destination))
throw new InvalidOperationException("现有 DSH-Portable 目录不完整;没有修改任何文件。");
reportStatus("正在停止当前版本…");
StopRunningPortable();
+ reportStatus("正在刷新 DSH profile 模块映射…");
+ ResetManagedProfileModuleFallback();
reportStatus("正在安装新版本并保留个人数据…");
ReplacePortableTransactionally(extracted, Path.Combine(destinationParent, ".dsh-portable-backup-" + operationId));
}
@@ -209,9 +267,9 @@ internal async Task ExecuteAsync(CancellationToken cancellation
Directory.Move(extracted, options.Destination);
}
reportProgress(payload.Bytes, payload.Bytes);
- reportStatus(options.UpgradeExisting ? "DSH-Portable 已更新完成。" : "DSH-Portable 已准备完成。");
+ reportStatus(upgradeExisting ? "DSH-Portable 已更新完成。" : "DSH-Portable 已准备完成。");
LaunchIfRequested();
- return Result(options.UpgradeExisting ? "updated" : "installed", manifest.Version, null);
+ return Result(upgradeExisting ? "updated" : "installed", manifest.Version, null);
}
finally
{
@@ -243,6 +301,97 @@ internal static bool IsCompletePortable(string root)
&& File.Exists(Path.Combine(root, "app", "package.json"));
}
+ private static string ReadInstalledPortableVersion(string root)
+ {
+ string filename = Path.Combine(root, "licenses", "COMPONENTS.json");
+ if (!File.Exists(filename)) return null;
+ try
+ {
+ using (FileStream stream = File.OpenRead(filename))
+ {
+ DataContractJsonSerializer serializer = new DataContractJsonSerializer(typeof(InstalledComponents));
+ InstalledComponents components = (InstalledComponents)serializer.ReadObject(stream);
+ return components == null || String.IsNullOrWhiteSpace(components.PortableVersion)
+ ? null
+ : components.PortableVersion.Trim();
+ }
+ }
+ catch { return null; }
+ }
+
+ private static SemanticVersion ParsePortableVersion(string value)
+ {
+ Match match = Regex.Match(
+ value ?? String.Empty,
+ @"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$");
+ long major;
+ long minor;
+ long patch;
+ if (!match.Success
+ || !Int64.TryParse(match.Groups[1].Value, out major)
+ || !Int64.TryParse(match.Groups[2].Value, out minor)
+ || !Int64.TryParse(match.Groups[3].Value, out patch))
+ throw new InvalidDataException((value ?? String.Empty) + " is not a valid semantic version.");
+
+ string[] prerelease = null;
+ string rawPrerelease = match.Groups[4].Success ? match.Groups[4].Value : null;
+ if (!String.IsNullOrEmpty(rawPrerelease))
+ {
+ Match portablePreview = Regex.Match(rawPrerelease, @"^rc\.([0-9]+)-portable\.([0-9]+)$");
+ prerelease = portablePreview.Success
+ ? new[] { "rc", portablePreview.Groups[1].Value, "portable", portablePreview.Groups[2].Value }
+ : rawPrerelease.Split('.');
+ }
+ return new SemanticVersion { Major = major, Minor = minor, Patch = patch, Prerelease = prerelease };
+ }
+
+ private static int CompareNumericIdentifier(string left, string right)
+ {
+ string normalizedLeft = left.TrimStart('0');
+ string normalizedRight = right.TrimStart('0');
+ if (normalizedLeft.Length == 0) normalizedLeft = "0";
+ if (normalizedRight.Length == 0) normalizedRight = "0";
+ if (normalizedLeft.Length != normalizedRight.Length) return normalizedLeft.Length < normalizedRight.Length ? -1 : 1;
+ int compared = String.CompareOrdinal(normalizedLeft, normalizedRight);
+ return compared == 0 ? 0 : compared < 0 ? -1 : 1;
+ }
+
+ private static int ComparePrereleaseIdentifier(string left, string right)
+ {
+ bool leftNumeric = Regex.IsMatch(left, "^[0-9]+$");
+ bool rightNumeric = Regex.IsMatch(right, "^[0-9]+$");
+ if (leftNumeric && rightNumeric) return CompareNumericIdentifier(left, right);
+ if (leftNumeric != rightNumeric) return leftNumeric ? -1 : 1;
+ int compared = String.CompareOrdinal(left, right);
+ return compared == 0 ? 0 : compared < 0 ? -1 : 1;
+ }
+
+ private static int ComparePortableVersions(string leftValue, string rightValue)
+ {
+ SemanticVersion left = ParsePortableVersion(leftValue);
+ SemanticVersion right = ParsePortableVersion(rightValue);
+ long[] leftCore = { left.Major, left.Minor, left.Patch };
+ long[] rightCore = { right.Major, right.Minor, right.Patch };
+ for (int index = 0; index < 3; index += 1)
+ {
+ if (leftCore[index] != rightCore[index]) return leftCore[index] < rightCore[index] ? -1 : 1;
+ }
+ if (left.Prerelease == null || right.Prerelease == null)
+ {
+ if (left.Prerelease == null && right.Prerelease == null) return 0;
+ return left.Prerelease == null ? 1 : -1;
+ }
+ int length = Math.Max(left.Prerelease.Length, right.Prerelease.Length);
+ for (int index = 0; index < length; index += 1)
+ {
+ if (index >= left.Prerelease.Length) return -1;
+ if (index >= right.Prerelease.Length) return 1;
+ int compared = ComparePrereleaseIdentifier(left.Prerelease[index], right.Prerelease[index]);
+ if (compared != 0) return compared;
+ }
+ return 0;
+ }
+
private void LaunchIfRequested()
{
if (options.NoLaunch) return;
@@ -294,6 +443,16 @@ private static bool HasRunningLauncher(string launcher)
return false;
}
+ private void ResetManagedProfileModuleFallback()
+ {
+ string fallback = Path.Combine(options.Destination, "data", "dsh-home", "profiles", "node_modules");
+ if (!Directory.Exists(fallback) && !File.Exists(fallback)) return;
+ if (Directory.Exists(fallback)) TryDeleteDirectory(fallback);
+ else TryDeleteFile(fallback);
+ if (Directory.Exists(fallback) || File.Exists(fallback))
+ throw new IOException("无法刷新 DSH profile 的可再生模块映射;没有替换程序文件。");
+ }
+
private void ReplacePortableTransactionally(string extracted, string backupRoot)
{
string[] preserved = new[] { "data", "workspace", "installed-mode.json" };
@@ -345,9 +504,12 @@ private static void DeleteEntry(string value)
else TryDeleteFile(value);
}
- private static async Task DownloadManifestAsync(string url, CancellationToken cancellationToken)
+ private static async Task DownloadManifestAsync(
+ string url,
+ CancellationToken cancellationToken,
+ TimeSpan? timeout = null)
{
- using (HttpClient client = CreateClient())
+ using (HttpClient client = CreateClient(timeout))
using (HttpResponseMessage response = await client.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
@@ -394,10 +556,10 @@ private async Task DownloadFileAsync(string url, string destination, long expect
}
}
- private static HttpClient CreateClient()
+ private static HttpClient CreateClient(TimeSpan? timeout = null)
{
HttpClientHandler handler = new HttpClientHandler { AllowAutoRedirect = true };
- HttpClient client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(30) };
+ HttpClient client = new HttpClient(handler) { Timeout = timeout ?? TimeSpan.FromMinutes(30) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("DSH-Portable-Bootstrap/1.0");
return client;
}
diff --git a/launcher/windows/DSH-Command.cs b/launcher/windows/DSH-Command.cs
index f6a8c938..a14f5434 100644
--- a/launcher/windows/DSH-Command.cs
+++ b/launcher/windows/DSH-Command.cs
@@ -7,8 +7,8 @@
[assembly: AssemblyTitle("DSH-Portable Command")]
[assembly: AssemblyProduct("DSH-Portable")]
[assembly: AssemblyCompany("WSL043")]
-[assembly: AssemblyVersion("0.2.4.65534")]
-[assembly: AssemblyFileVersion("0.2.4.65534")]
+[assembly: AssemblyVersion("0.2.5.65534")]
+[assembly: AssemblyFileVersion("0.2.5.65534")]
internal static class DshCommand
{
diff --git a/launcher/windows/DSH-Portable.cs b/launcher/windows/DSH-Portable.cs
index 34e06462..831e6a42 100644
--- a/launcher/windows/DSH-Portable.cs
+++ b/launcher/windows/DSH-Portable.cs
@@ -21,8 +21,8 @@
[assembly: AssemblyCompany("WSL043")]
[assembly: AssemblyProduct("DeepSeek-Herness")]
[assembly: AssemblyCopyright("Copyright © WSL043 2026")]
-[assembly: AssemblyVersion("0.2.4.65534")]
-[assembly: AssemblyFileVersion("0.2.4.65534")]
+[assembly: AssemblyVersion("0.2.5.65534")]
+[assembly: AssemblyFileVersion("0.2.5.65534")]
namespace DshPortable
{
@@ -251,6 +251,7 @@ private enum WindowCloseBehavior { Tray, Exit }
private static string uiLanguage = CultureInfo.InstalledUICulture.TwoLetterISOLanguageName;
private enum DwmWindowCornerPreference { Default = 0, DoNotRound = 1, Round = 2, RoundSmall = 3 }
private const int DwmwaWindowCornerPreference = 33;
+ private const int WorkspaceNavigationTimeoutMs = 60000;
private static string L(string chinese, string english)
{
@@ -1428,11 +1429,11 @@ private async Task NavigateDesktopAsync(string url)
};
webView.CoreWebView2.NavigationCompleted += completed;
webView.CoreWebView2.Navigate(url);
- Task winner = await Task.WhenAny(navigation.Task, Task.Delay(30000));
+ Task winner = await Task.WhenAny(navigation.Task, Task.Delay(WorkspaceNavigationTimeoutMs));
if (winner != navigation.Task)
{
webView.CoreWebView2.NavigationCompleted -= completed;
- throw new TimeoutException(L("更新后的工作台未能在 30 秒内打开。", "The updated workspace did not open within 30 seconds."));
+ throw new TimeoutException(L("更新后的工作台未能在 60 秒内打开。", "The updated workspace did not open within 60 seconds."));
}
CoreWebView2NavigationCompletedEventArgs result = await navigation.Task;
if (!result.IsSuccess) throw new InvalidOperationException(L("更新后的工作台加载失败:", "The updated workspace could not load: ") + result.WebErrorStatus);
@@ -1494,10 +1495,10 @@ private async Task ShowDesktopAsync(string url)
webView.CoreWebView2.NavigationCompleted += navigationCompleted;
webView.Source = applicationUri;
- Task completed = await Task.WhenAny(navigation.Task, Task.Delay(30000));
+ Task completed = await Task.WhenAny(navigation.Task, Task.Delay(WorkspaceNavigationTimeoutMs));
webView.CoreWebView2.NavigationCompleted -= navigationCompleted;
if (completed != navigation.Task)
- throw new TimeoutException(L("DeepSeek Harness 工作台未能在 30 秒内打开。", "The DeepSeek Harness workspace did not open within 30 seconds."));
+ throw new TimeoutException(L("DeepSeek Harness 工作台未能在 60 秒内打开。", "The DeepSeek Harness workspace did not open within 60 seconds."));
CoreWebView2NavigationCompletedEventArgs result = await navigation.Task;
if (!result.IsSuccess)
throw new InvalidOperationException(L("DeepSeek Harness 工作台加载失败:", "The DeepSeek Harness workspace could not load: ") + result.WebErrorStatus);
diff --git a/package.json b/package.json
index 1942f355..0dfbcd9f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dsh-portable",
- "version": "0.2.4",
+ "version": "0.2.5",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/scripts/smoke-windows-portable-extractor.ps1 b/scripts/smoke-windows-portable-extractor.ps1
index 5a1663d5..e4ff460f 100644
--- a/scripts/smoke-windows-portable-extractor.ps1
+++ b/scripts/smoke-windows-portable-extractor.ps1
@@ -57,6 +57,33 @@ if (Get-ChildItem -LiteralPath $ExtractRoot -Filter 'unins*.exe' -File) {
throw 'portable extraction created an uninstaller'
}
+Write-Host '::group::Reject overwrite of an existing portable folder'
+$Sentinel = Join-Path $ExtractRoot 'data\extractor-overwrite-sentinel.txt'
+$SentinelText = 'keep-existing-user-data'
+Set-Content -LiteralPath $Sentinel -Value $SentinelText -NoNewline -Encoding UTF8
+$OverwriteLog = Join-Path $TestParent 'extractor-overwrite.log'
+$OverwriteArguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NOCANCEL /NORESTART /CURRENTUSER /DIR="{0}" /LOG="{1}"' -f $ExtractRoot, $OverwriteLog
+$OverwriteProcess = Start-Process -FilePath $Extractor -ArgumentList $OverwriteArguments -PassThru
+try {
+ if (-not $OverwriteProcess.WaitForExit(120000)) {
+ & taskkill.exe /PID $OverwriteProcess.Id /T /F 2>&1 | ForEach-Object { Write-Host $_ }
+ $OverwriteProcess.WaitForExit(10000) | Out-Null
+ throw 'portable overwrite rejection timed out after 120 seconds'
+ }
+ $OverwriteProcess.Refresh()
+ if ($OverwriteProcess.ExitCode -eq 0) {
+ throw 'portable self-extractor unexpectedly overwrote an existing DSH-Portable folder'
+ }
+ if (-not (Test-Path -LiteralPath $Sentinel)) {
+ throw 'portable self-extractor removed existing user data while rejecting overwrite'
+ }
+ if ((Get-Content -LiteralPath $Sentinel -Raw) -ne $SentinelText) {
+ throw 'portable self-extractor changed existing user data while rejecting overwrite'
+ }
+} finally {
+ Write-Host '::endgroup::'
+}
+
Write-Host '::group::Run movable portable smoke test'
try {
$PortableNode = Join-Path $ExtractRoot 'runtime\node\node.exe'
diff --git a/templates/RELEASE-NOTES.md b/templates/RELEASE-NOTES.md
index 90188449..dc8ae17f 100644
--- a/templates/RELEASE-NOTES.md
+++ b/templates/RELEASE-NOTES.md
@@ -1,22 +1,24 @@
> 打包官方 DeepSeek Harness 预览版(`@deepseek-ai/dsh 0.1.0-rc.7`)。DSH-Portable 是独立社区分发项目。
-0.2.4 同步官方 rc.7,并继续保持便携数据与桌面体验:
-
-- 设置页现在可显示插件注册的设置卡;Job Panel 可呈现 Codex、Claude Code 等外部 Agent
- 启动的子任务。
-- MCP、ACP 与嵌套 PTC 工具返回的图片可保留在对话上下文中。
-- 修复大段历史记录分页可能导致的栈溢出,以及达到最大 Token 后会话无法继续的问题。
-- 改善最小模式下持续 Bash 调用的延迟;问题卡片可折叠,并保留尚未提交的答案草稿。
-- DeepSeek 模型增加 `low` 推理强度选项;原 Code 模式统一更名为 PTC 模式。
-- Portable 已适配官方新版终端运行时;Windows、macOS、Linux x64 与 ARM64 继续使用同一套
- 完整发布门。
-- “启动时检查更新”仍可在应用菜单中关闭;关闭后不会自动提醒,仍可随时手动检查。
-
-普通更新只替换 DSH 应用组件并保留用户数据。
-
-从旧版升级时,如果启动器兼容边界变化,会下载一次完整版本并原地更新;`data`、
-`workspace`、会话、凭据和插件都会保留。Windows、macOS、Linux x64 与 ARM64 成品仍由
-同一发布门验证。
+0.2.5 继续使用官方 rc.7,重点修复 Windows 便携版的升级与启动可靠性:
+
+- **轻量便携启动器现在会安全检查并升级已有版本。** 发现新版时先停止当前 DSH,再保留
+ `data`、`workspace`、会话、凭据和插件完成事务替换;同版本直接启动。网络或更新通道
+ 暂时不可用时,也会继续启动本地版本,不把日常使用变成联网必需。
+- **完整升级会重建 DSH 自动生成的 profile 模块映射。** 用户 profile、设置、会话、
+ profile 内安装的插件和工作区保持不动,避免新旧 runtime 混用后出现
+ `ERR_MODULE_NOT_FOUND`。
+- **Windows 离线自解压构建不再覆盖已有的非空便携目录。** 这样不会把新程序文件与旧
+ profile/runtime 混成半新半旧;升级已有目录请使用轻量便携启动器。
+- **原生 WebView2 工作台启动等待从 30 秒提高到 60 秒。** 较慢机器或首次初始化时不再
+ 过早判定工作台启动失败。
+- **“启动时检查更新”仍可在应用菜单中关闭。** 关闭后不会自动提醒,手动“检查更新”
+ 入口仍会保留。
+- Windows、macOS、Linux x64 与 ARM64 继续经过同一套 contracts、真实成品构建、更新、
+ 移动和桌面生命周期测试后才进入发布通道。
+
+普通更新只替换 DSH 应用组件并保留用户数据;跨启动器兼容边界时才会下载一次完整版本并
+安全原地升级。
## Windows x64(推荐)
@@ -46,24 +48,26 @@
> Packages the official DeepSeek Harness preview (`@deepseek-ai/dsh 0.1.0-rc.7`).
> DSH-Portable is an independent community distribution.
-0.2.4 updates the bundled official runtime to rc.7 while preserving portable data and desktop behavior:
-
-- Settings can now show plugin-registered cards, and the Job Panel can surface subagent tasks launched
- by external tools such as Codex and Claude Code.
-- Images returned through MCP, ACP, and nested PTC tools remain available in conversation context.
-- Large-history pagination no longer risks a stack overflow, and sessions remain usable after
- max-token truncation.
-- Persistent Bash work in minimal mode has lower latency. Question cards can collapse without losing
- unsubmitted answer drafts.
-- DeepSeek models gain a `low` reasoning-effort option, and Code mode is now named PTC mode.
-- The Portable packages support the updated terminal runtime across Windows, macOS, Linux x64, and
- Linux ARM64 through the same release gate.
-- **Check for updates at startup** remains optional. Turn it off to suppress automatic prompts while
+0.2.5 keeps the official rc.7 runtime and focuses on safer, more reliable Windows portable updates and startup:
+
+- **The lightweight portable launcher now checks and upgrades an existing installation safely.** When a
+ newer version is available, it stops the current DSH process and performs a transactional full-package
+ replacement while preserving `data`, `workspace`, sessions, credentials, and plugins. If the update
+ service or network is unavailable, the installed version still starts normally.
+- **Full-package upgrades rebuild only DSH's generated profile module fallback.** Profile settings,
+ sessions, profile-local plugins, and workspace data remain untouched, avoiding `ERR_MODULE_NOT_FOUND`
+ failures caused by mixing a new runtime with stale generated module mappings.
+- **The Windows offline self-extractor no longer overwrites a non-empty portable folder.** This prevents
+ half-old/half-new installations; use the lightweight portable launcher when upgrading an existing folder.
+- **The native WebView2 workspace startup window increases from 30 seconds to 60 seconds**, avoiding
+ premature startup failures on slower systems and first-run initialization.
+- **Check for updates at startup remains optional.** Turn it off to suppress automatic prompts while
keeping manual update checks available.
+- Windows, macOS, Linux x64, and Linux ARM64 continue through the same contracts, finished-product build,
+ update, movable-package, and desktop lifecycle release gates.
-If an older launcher crosses a compatibility boundary, it downloads one complete package and updates
-in place while preserving `data`, `workspace`, sessions, credentials, and plugins. Finished products
-for Windows, macOS, Linux x64, and Linux ARM64 continue through the same release gate.
+Normal updates replace only the DSH application component and preserve user data. A full package is downloaded
+only when the launcher/runtime compatibility boundary requires it.
### Windows x64 (recommended)
diff --git a/tests/bootstrap-auto-upgrade-contract.test.mjs b/tests/bootstrap-auto-upgrade-contract.test.mjs
new file mode 100644
index 00000000..e1d210c2
--- /dev/null
+++ b/tests/bootstrap-auto-upgrade-contract.test.mjs
@@ -0,0 +1,160 @@
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import { execFile } from 'node:child_process'
+import { createServer } from 'node:http'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import test from 'node:test'
+
+const execFileAsync = promisify(execFile)
+const projectRoot = path.resolve(import.meta.dirname, '..')
+const bootstrapSource = path.join(projectRoot, 'launcher', 'windows', 'DSH-Bootstrap.cs')
+
+function cscPath() {
+ const windows = process.env.WINDIR || 'C:\\Windows'
+ return path.join(windows, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe')
+}
+
+async function compileBootstrap(output) {
+ await execFileAsync(cscPath(), [
+ '/nologo', '/target:winexe', '/platform:x64', '/optimize+',
+ '/reference:System.dll', '/reference:System.Core.dll',
+ '/reference:System.Drawing.dll', '/reference:System.Windows.Forms.dll',
+ '/reference:System.Net.Http.dll', '/reference:System.Runtime.Serialization.dll',
+ '/reference:System.IO.Compression.dll',
+ `/out:${output}`,
+ bootstrapSource,
+ ])
+}
+
+async function makePayload(root, version) {
+ const packageRoot = path.join(root, 'payload', 'DSH-Portable')
+ await mkdir(path.join(packageRoot, 'runtime', 'node'), { recursive: true })
+ await mkdir(path.join(packageRoot, 'app'), { recursive: true })
+ await mkdir(path.join(packageRoot, 'licenses'), { recursive: true })
+ await mkdir(path.join(packageRoot, 'data'), { recursive: true })
+ await mkdir(path.join(packageRoot, 'workspace'), { recursive: true })
+ await writeFile(path.join(packageRoot, 'DeepSeek-Herness.exe'), 'new launcher\n')
+ await writeFile(path.join(packageRoot, 'runtime', 'node', 'node.exe'), 'new node\n')
+ await writeFile(path.join(packageRoot, 'app', 'package.json'), '{"name":"new-app"}\n')
+ await writeFile(path.join(packageRoot, 'licenses', 'COMPONENTS.json'), `${JSON.stringify({ portableVersion: version })}\n`)
+ const archive = path.join(root, 'payload.zip')
+ await execFileAsync('tar.exe', ['-a', '-c', '-f', archive, '-C', path.join(root, 'payload'), 'DSH-Portable'])
+ const bytes = await readFile(archive)
+ return { archive, bytes, sha256: createHash('sha256').update(bytes).digest('hex') }
+}
+
+async function createOldInstall(destination) {
+ await mkdir(path.join(destination, 'runtime', 'node'), { recursive: true })
+ await mkdir(path.join(destination, 'app'), { recursive: true })
+ await mkdir(path.join(destination, 'licenses'), { recursive: true })
+ await mkdir(path.join(destination, 'workspace'), { recursive: true })
+ await writeFile(path.join(destination, 'DeepSeek-Herness.exe'), 'old launcher\n')
+ await writeFile(path.join(destination, 'runtime', 'node', 'node.exe'), 'old node\n')
+ await writeFile(path.join(destination, 'app', 'package.json'), '{"name":"old-app"}\n')
+ await writeFile(path.join(destination, 'licenses', 'COMPONENTS.json'), `${JSON.stringify({ portableVersion: '0.2.4' })}\n`)
+
+ const profileSettings = path.join(destination, 'data', 'dsh-home', 'profiles', 'web', 'settings.json')
+ const userPlugin = path.join(destination, 'data', 'dsh-home', 'profiles', 'web', 'node_modules', 'example-user-plugin', 'package.json')
+ const staleFallback = path.join(destination, 'data', 'dsh-home', 'profiles', 'node_modules', '@deepseek-ai', 'dsh-client-ui-plan', 'stale.txt')
+ await mkdir(path.dirname(profileSettings), { recursive: true })
+ await mkdir(path.dirname(userPlugin), { recursive: true })
+ await mkdir(path.dirname(staleFallback), { recursive: true })
+ await writeFile(profileSettings, '{"keep":true}\n')
+ await writeFile(userPlugin, '{"name":"example-user-plugin"}\n')
+ await writeFile(staleFallback, 'stale generated fallback\n')
+ await writeFile(path.join(destination, 'workspace', 'project.txt'), 'keep workspace\n')
+}
+
+async function exists(filename) {
+ try {
+ await stat(filename)
+ return true
+ } catch (error) {
+ if (error?.code === 'ENOENT') return false
+ throw error
+ }
+}
+
+test('bootstrap automatically upgrades an older complete install and stays usable offline', { skip: process.platform !== 'win32' }, async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'dsh-bootstrap-auto-upgrade-'))
+ const destination = path.join(root, 'existing', 'DSH-Portable')
+ const executable = path.join(root, 'bootstrap.exe')
+ const resultFile = path.join(root, 'result.json')
+ const payload = await makePayload(root, '0.2.5')
+ let manifestRequests = 0
+ let payloadRequests = 0
+ let server
+
+ try {
+ await compileBootstrap(executable)
+ await createOldInstall(destination)
+
+ server = createServer((request, response) => {
+ const origin = `http://127.0.0.1:${server.address().port}`
+ if (request.url === '/portable-manifest.json') {
+ manifestRequests += 1
+ const body = Buffer.from(JSON.stringify({
+ schemaVersion: 1,
+ version: '0.2.5',
+ payloads: {
+ windowsX64: {
+ filename: 'payload.zip',
+ url: `${origin}/payload.zip`,
+ sha256: payload.sha256,
+ bytes: payload.bytes.length,
+ },
+ },
+ }))
+ response.writeHead(200, { 'content-type': 'application/json', 'content-length': body.length }).end(body)
+ return
+ }
+ if (request.url === '/payload.zip') {
+ payloadRequests += 1
+ response.writeHead(200, { 'content-type': 'application/zip', 'content-length': payload.bytes.length }).end(payload.bytes)
+ return
+ }
+ response.writeHead(404).end()
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const origin = `http://127.0.0.1:${server.address().port}`
+
+ await execFileAsync(executable, [
+ '--manifest', `${origin}/portable-manifest.json`,
+ '--destination', destination,
+ '--allow-http',
+ '--no-launch',
+ '--result', resultFile,
+ ], { timeout: 120000, windowsHide: true })
+
+ const updated = JSON.parse(await readFile(resultFile, 'utf8'))
+ assert.equal(updated.status, 'updated')
+ assert.equal(updated.version, '0.2.5')
+ assert.equal(manifestRequests, 1)
+ assert.equal(payloadRequests, 1)
+ assert.equal(await readFile(path.join(destination, 'app', 'package.json'), 'utf8'), '{"name":"new-app"}\n')
+ assert.equal(await readFile(path.join(destination, 'data', 'dsh-home', 'profiles', 'web', 'settings.json'), 'utf8'), '{"keep":true}\n')
+ assert.equal(await readFile(path.join(destination, 'data', 'dsh-home', 'profiles', 'web', 'node_modules', 'example-user-plugin', 'package.json'), 'utf8'), '{"name":"example-user-plugin"}\n')
+ assert.equal(await readFile(path.join(destination, 'workspace', 'project.txt'), 'utf8'), 'keep workspace\n')
+ assert.equal(await exists(path.join(destination, 'data', 'dsh-home', 'profiles', 'node_modules')), false)
+
+ await new Promise((resolve) => server.close(resolve))
+ server = null
+ await execFileAsync(executable, [
+ '--manifest', 'http://127.0.0.1:1/unreachable.json',
+ '--destination', destination,
+ '--allow-http',
+ '--no-launch',
+ '--result', resultFile,
+ ], { timeout: 30000, windowsHide: true })
+ const offline = JSON.parse(await readFile(resultFile, 'utf8'))
+ assert.equal(offline.status, 'ready')
+ assert.equal(offline.version, '0.2.5')
+ assert.equal(await readFile(path.join(destination, 'app', 'package.json'), 'utf8'), '{"name":"new-app"}\n')
+ } finally {
+ if (server) await new Promise((resolve) => server.close(resolve))
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/tests/profile-fallback-update.test.mjs b/tests/profile-fallback-update.test.mjs
new file mode 100644
index 00000000..c62ff9ba
--- /dev/null
+++ b/tests/profile-fallback-update.test.mjs
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import test from 'node:test'
+
+import { layoutForRoot } from '../launcher/portable-core.mjs'
+import { applyStagedAppUpdate } from '../launcher/update-core.mjs'
+
+const licenseFiles = [
+ 'COMPONENTS.json',
+ 'DeepSeek-Harness-LICENSE.txt',
+ 'DeepSeek-Harness-THIRD_PARTY_NOTICES.md',
+ 'pnpm-LICENSE.txt',
+]
+
+async function exists(filename) {
+ try {
+ await stat(filename)
+ return true
+ } catch (error) {
+ if (error?.code === 'ENOENT') return false
+ throw error
+ }
+}
+
+test('component updates rebuild only the managed profile module fallback', async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'dsh-profile-fallback-update-'))
+ const layout = layoutForRoot(root)
+ const stagedRoot = path.join(layout.updateDir, 'fixture', 'staged')
+ const dshRelative = path.join('node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
+ const profileSettings = path.join(layout.dshHome, 'profiles', 'web', 'settings.json')
+ const userPlugin = path.join(layout.dshHome, 'profiles', 'web', 'node_modules', 'example-user-plugin', 'package.json')
+ const managedFallback = path.join(layout.dshHome, 'profiles', 'node_modules')
+ const staleFallback = path.join(managedFallback, '@deepseek-ai', 'dsh-client-ui-plan', 'stale.txt')
+ const workspaceFile = path.join(layout.workspace, 'project.txt')
+
+ try {
+ await mkdir(path.dirname(path.join(layout.appDir, dshRelative)), { recursive: true })
+ await writeFile(path.join(layout.appDir, dshRelative), 'old app\n')
+ await mkdir(path.dirname(path.join(stagedRoot, 'app', dshRelative)), { recursive: true })
+ await writeFile(path.join(stagedRoot, 'app', dshRelative), 'new app\n')
+
+ await mkdir(path.join(root, 'licenses'), { recursive: true })
+ await mkdir(path.join(stagedRoot, 'licenses'), { recursive: true })
+ const oldComponents = {
+ portableVersion: '0.2.4',
+ dshVersion: '0.1.0-rc.6',
+ dshCommit: 'a'.repeat(40),
+ }
+ const newComponents = {
+ portableVersion: '0.2.5',
+ dshVersion: '0.1.0-rc.7',
+ dshCommit: 'b'.repeat(40),
+ }
+ for (const name of licenseFiles) {
+ await writeFile(
+ path.join(root, 'licenses', name),
+ name === 'COMPONENTS.json' ? `${JSON.stringify(oldComponents)}\n` : `old ${name}\n`,
+ )
+ await writeFile(
+ path.join(stagedRoot, 'licenses', name),
+ name === 'COMPONENTS.json' ? `${JSON.stringify(newComponents)}\n` : `new ${name}\n`,
+ )
+ }
+ await writeFile(path.join(stagedRoot, 'component.json'), `${JSON.stringify({
+ schemaVersion: 1,
+ kind: 'dsh-app',
+ portableVersion: newComponents.portableVersion,
+ dshVersion: newComponents.dshVersion,
+ dshCommit: newComponents.dshCommit,
+ })}\n`)
+
+ await mkdir(path.dirname(profileSettings), { recursive: true })
+ await mkdir(path.dirname(userPlugin), { recursive: true })
+ await mkdir(path.dirname(staleFallback), { recursive: true })
+ await mkdir(path.dirname(workspaceFile), { recursive: true })
+ await writeFile(profileSettings, '{"theme":"dark"}\n')
+ await writeFile(userPlugin, '{"name":"example-user-plugin"}\n')
+ await writeFile(staleFallback, 'stale generated link target\n')
+ await writeFile(workspaceFile, 'keep workspace\n')
+
+ let fallbackWasResetBeforeHealthCheck = false
+ const result = await applyStagedAppUpdate({
+ layout,
+ stagedRoot,
+ healthCheck: async () => {
+ fallbackWasResetBeforeHealthCheck = !await exists(managedFallback)
+ assert.equal(await readFile(profileSettings, 'utf8'), '{"theme":"dark"}\n')
+ assert.equal(await readFile(userPlugin, 'utf8'), '{"name":"example-user-plugin"}\n')
+ assert.equal(await readFile(workspaceFile, 'utf8'), 'keep workspace\n')
+ return true
+ },
+ })
+
+ assert.equal(result.status, 'updated')
+ assert.equal(fallbackWasResetBeforeHealthCheck, true)
+ assert.equal(await exists(managedFallback), false)
+ assert.equal(await readFile(path.join(layout.appDir, dshRelative), 'utf8'), 'new app\n')
+ assert.equal(await readFile(profileSettings, 'utf8'), '{"theme":"dark"}\n')
+ assert.equal(await readFile(userPlugin, 'utf8'), '{"name":"example-user-plugin"}\n')
+ assert.equal(await readFile(workspaceFile, 'utf8'), 'keep workspace\n')
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/tests/webview2-timeout-contract.test.mjs b/tests/webview2-timeout-contract.test.mjs
new file mode 100644
index 00000000..26d74e3d
--- /dev/null
+++ b/tests/webview2-timeout-contract.test.mjs
@@ -0,0 +1,17 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import test from 'node:test'
+
+const projectRoot = path.resolve(import.meta.dirname, '..')
+const launcherSource = path.join(projectRoot, 'launcher', 'windows', 'DSH-Portable.cs')
+
+test('Windows desktop host allows one minute for WebView2 workspace navigation', async () => {
+ const source = await readFile(launcherSource, 'utf8')
+ assert.match(source, /WorkspaceNavigationTimeoutMs\s*=\s*60000/)
+ assert.equal((source.match(/Task\.Delay\(WorkspaceNavigationTimeoutMs\)/g) ?? []).length, 2)
+ assert.doesNotMatch(source, /Task\.Delay\(30000\)/)
+ assert.doesNotMatch(source, /工作台未能在 30 秒内打开|workspace did not open within 30 seconds/)
+ assert.equal((source.match(/60 秒内打开/g) ?? []).length, 2)
+ assert.equal((source.match(/within 60 seconds/g) ?? []).length, 2)
+})