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) +})