diff --git a/internal/desktopapp/desktopapp_test.go b/internal/desktopapp/desktopapp_test.go index 3a7fb43..28532f4 100644 --- a/internal/desktopapp/desktopapp_test.go +++ b/internal/desktopapp/desktopapp_test.go @@ -856,13 +856,13 @@ func TestVerifyWindowsInstallerPassesPathViaEnvironment(t *testing.T) { } } -func TestWorkBuddyWindowsVersionPassesPathViaEnvironment(t *testing.T) { +func TestWindowsProductVersionPassesPathViaEnvironment(t *testing.T) { runner := &scriptedRunner{results: []process.Result{{ExitCode: 0, Stdout: "5.3.11\n"}}} path := `C:\Users\test\AppData\Local\Programs\WorkBuddy\WorkBuddy.exe` - version := workBuddyWindowsVersion(context.Background(), Options{Runner: runner}, path) + version := windowsProductVersion(context.Background(), Options{Runner: runner}, path) if version == nil || *version != "5.3.11" { - t.Fatalf("workBuddyWindowsVersion() = %v, want 5.3.11", version) + t.Fatalf("windowsProductVersion() = %v, want 5.3.11", version) } argv := runner.calls[0] if len(argv) != 5 { diff --git a/internal/desktopapp/dsh.go b/internal/desktopapp/dsh.go index be6680a..e39aaf2 100644 --- a/internal/desktopapp/dsh.go +++ b/internal/desktopapp/dsh.go @@ -248,7 +248,10 @@ func inspectDSH(ctx context.Context, options Options) Status { case "windows": for _, candidate := range dshWindowsCandidates(options) { if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + // The on-disk ProductVersion is authoritative for the same reason + // as the macOS plist: electron-updater replaces the app in place. status.Installed, status.Path = true, candidate + status.Version = windowsProductVersion(ctx, options, candidate) return status } } @@ -302,9 +305,8 @@ func inspectDSHMacOS(ctx context.Context, options Options) (Status, error) { // dshWindowsCandidates lists where the vendor's NSIS installer places the app. // It is configured perMachine: false with a fixed directory, which for -// electron-builder is %LOCALAPPDATA%\Programs\. Not verified on -// Windows: this machine is macOS, so the path follows electron-builder's -// documented default rather than an observed installation. +// electron-builder is %LOCALAPPDATA%\Programs\. Observed on a +// Windows 11 install of 0.1.7-rc.2. func dshWindowsCandidates(options Options) []string { if len(options.SearchRoots) > 0 { candidates := make([]string, 0, len(options.SearchRoots)) diff --git a/internal/desktopapp/dsh_test.go b/internal/desktopapp/dsh_test.go index e65b5bd..afbce8a 100644 --- a/internal/desktopapp/dsh_test.go +++ b/internal/desktopapp/dsh_test.go @@ -330,12 +330,59 @@ func TestInspectDSHWindowsLooksInThePerUserProgramsDirectory(t *testing.T) { if err := os.WriteFile(exe, []byte("MZ"), 0o600); err != nil { t.Fatal(err) } - status = Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64")}) + status = Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64"), Runner: &scriptedRunner{}}) if !status.Installed || status.Path != exe { t.Fatalf("Inspect() after install = %#v", status) } } +// The app replaces itself through electron-updater, so the version reported is +// the installed exe's ProductVersion, read the way a real 0.1.7-rc.2 install +// reports it: PowerShell's UTF-8 output with a BOM and a trailing newline. +func TestInspectDSHWindowsReportsTheInstalledProductVersion(t *testing.T) { + home := t.TempDir() + exe := filepath.Join(home, "AppData", "Local", "Programs", "DeepSeek Harness", dshDesktopExeName) + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, []byte("MZ"), 0o600); err != nil { + t.Fatal(err) + } + runner := &scriptedRunner{results: []process.Result{{ExitCode: 0, Stdout: "\ufeff0.1.7-rc.2\r\n"}}} + status := Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64"), Runner: runner}) + if !status.Installed || status.Path != exe { + t.Fatalf("Inspect() = %#v", status) + } + if status.Version == nil || *status.Version != "0.1.7-rc.2" { + t.Fatalf("Inspect().Version = %v, want 0.1.7-rc.2", status.Version) + } + if len(runner.calls) != 1 { + t.Fatalf("runner calls = %#v, want one version query", runner.calls) + } + if argv := runner.calls[0]; len(argv) != 5 || argv[0] != "powershell.exe" || strings.Contains(argv[4], exe) { + t.Fatalf("version query argv = %#v; the path must not be interpolated into the script", argv) + } + if got := runner.environments[0]["BOOTAGENT_VERSION_PATH"]; got != exe { + t.Fatalf("BOOTAGENT_VERSION_PATH = %q, want %q", got, exe) + } +} + +func TestInspectDSHWindowsLeavesVersionUnsetWhenTheQueryFails(t *testing.T) { + home := t.TempDir() + exe := filepath.Join(home, "AppData", "Local", "Programs", "DeepSeek Harness", dshDesktopExeName) + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, []byte("MZ"), 0o600); err != nil { + t.Fatal(err) + } + runner := &scriptedRunner{results: []process.Result{{ExitCode: 1, Stderr: "Get-Item : access denied"}}} + status := Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64"), Runner: runner}) + if !status.Installed || status.Version != nil || status.InspectionUnavailable != nil { + t.Fatalf("Inspect() = %#v, want installed with no version", status) + } +} + // Not offered means both halves: Supported is false so the UI shows no install // entry, and Install refuses if called anyway. func TestDSHIsNotOfferedOnIntelMacOrLinux(t *testing.T) { diff --git a/internal/desktopapp/windows_verify.go b/internal/desktopapp/windows_verify.go index 1d1b54b..4b0ae58 100644 --- a/internal/desktopapp/windows_verify.go +++ b/internal/desktopapp/windows_verify.go @@ -135,6 +135,19 @@ func approvedWindowsSigner(value string, allowed []string) bool { return false } +// windowsProductVersion reads the executable's VersionInfo.ProductVersion. It +// returns nil when the query fails: an unknown version is not an inspection +// failure worth surfacing. +func windowsProductVersion(ctx context.Context, options Options, path string) *string { + const script = `[Console]::OutputEncoding = [Text.Encoding]::UTF8 +(Get-Item -LiteralPath $env:BOOTAGENT_VERSION_PATH).VersionInfo.ProductVersion` + result, err := runWithEnvironment(options, ctx, []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script}, map[string]string{"BOOTAGENT_VERSION_PATH": path}, inspectTimeout) + if err != nil || result.ExitCode != 0 { + return nil + } + return nonEmptyPointer(strings.TrimPrefix(strings.TrimSpace(result.Stdout), "\ufeff")) +} + func windowsAuthenticodeQuery() []string { const script = `[Console]::OutputEncoding = [Text.Encoding]::UTF8 $signature = Get-AuthenticodeSignature -LiteralPath $env:BOOTAGENT_VERIFY_PATH diff --git a/internal/desktopapp/workbuddy.go b/internal/desktopapp/workbuddy.go index 40d32e6..0028b16 100644 --- a/internal/desktopapp/workbuddy.go +++ b/internal/desktopapp/workbuddy.go @@ -215,7 +215,7 @@ func inspectWorkBuddyWindows(ctx context.Context, edition workBuddyEdition, opti } if !info.IsDir() { status.Installed, status.Path = true, candidate - status.Version = workBuddyWindowsVersion(ctx, options, candidate) + status.Version = windowsProductVersion(ctx, options, candidate) return status, nil } } @@ -276,16 +276,6 @@ func workBuddyWindowsCandidates(edition workBuddyEdition, options Options) []str return result } -func workBuddyWindowsVersion(ctx context.Context, options Options, path string) *string { - const script = `[Console]::OutputEncoding = [Text.Encoding]::UTF8 -(Get-Item -LiteralPath $env:BOOTAGENT_VERSION_PATH).VersionInfo.ProductVersion` - result, err := runWithEnvironment(options, ctx, []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script}, map[string]string{"BOOTAGENT_VERSION_PATH": path}, inspectTimeout) - if err != nil || result.ExitCode != 0 { - return nil - } - return nonEmptyPointer(strings.TrimSpace(result.Stdout)) -} - // workBuddyStartAppsQuery matches on the Start menu display name, which carries // no ".app" suffix. The name reaches the script through an environment variable // rather than string interpolation, so quoting in the display name cannot alter