From c7d908768f92fc099b8636a94f72701e4df6da8d Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:07:00 +0800 Subject: [PATCH 01/17] fix(build): shorten packaged asset names --- apps/desktop/scripts/vite-config.test.ts | 15 +++++++++++++++ apps/desktop/vite.config.ts | 4 ++++ 2 files changed, 19 insertions(+) create mode 100644 apps/desktop/scripts/vite-config.test.ts diff --git a/apps/desktop/scripts/vite-config.test.ts b/apps/desktop/scripts/vite-config.test.ts new file mode 100644 index 00000000..87cac5db --- /dev/null +++ b/apps/desktop/scripts/vite-config.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import viteConfig from "../vite.config"; + +describe("desktop Vite packaging", () => { + test("uses short imported-asset names for the Windows tar extractor", () => { + const output = viteConfig.build?.rollupOptions?.output; + + expect(output).toBeDefined(); + expect(Array.isArray(output)).toBe(false); + if (!output || Array.isArray(output)) return; + + expect(output.assetFileNames).toBe("assets/[hash][extname]"); + }); +}); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 46c0b105..0282b63a 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -36,6 +36,10 @@ export default defineConfig({ chunkSizeWarningLimit: 700, rollupOptions: { output: { + // Electrobun's Windows extractor cannot read GNU LongLink records. Keep + // every imported asset name short so the packaged tar stays within the + // portable tar header path limit regardless of the source file name. + assetFileNames: "assets/[hash][extname]", // Split the stable, heavy vendor libraries out of the app chunk. They // change far less often than app code, so isolating them means an // app-code update ships a small delta for the auto-updating desktop From 5a48df43ec7444e561ffd308bfb8f4f4d3ea303b Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:21:20 +0800 Subject: [PATCH 02/17] feat(build): configure and verify Windows x64 artifacts --- apps/desktop/electrobun.config.ts | 11 +- apps/desktop/icon.ico | Bin 0 -> 79745 bytes apps/desktop/package.json | 5 +- apps/desktop/scripts/post-build.ts | 39 ++++ apps/desktop/scripts/post-package.ts | 75 ++++++++ apps/desktop/scripts/smoke-test-windows.ps1 | 168 +++++++++++++++++ .../scripts/verify-windows-package.test.ts | 118 ++++++++++++ .../desktop/scripts/verify-windows-package.ts | 175 ++++++++++++++++++ bun.lock | 3 +- mise.toml | 16 ++ 10 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/icon.ico create mode 100644 apps/desktop/scripts/post-build.ts create mode 100644 apps/desktop/scripts/post-package.ts create mode 100644 apps/desktop/scripts/smoke-test-windows.ps1 create mode 100644 apps/desktop/scripts/verify-windows-package.test.ts create mode 100644 apps/desktop/scripts/verify-windows-package.ts diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index c9f1fd22..71f44753 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -87,14 +87,19 @@ export default { bundleCEF: false, }, win: { + // Windows ships only the regular WebView2 edition for now. bundleCEF: false, + icon: "icon.ico", }, }, scripts: { - // Both run right before their respective codesign step. Workaround for - // electrobun#485 (x64-only, no-op elsewhere); see the script header. - postBuild: "scripts/fix-x64-headerpad.ts", + // The inner-app hook preserves the macOS x64 headerpad workaround and also + // embeds the Windows icon before the updater tarball is compressed. The + // wrapper hook remains macOS-only; Windows Setup.exe is finalized after + // packaging because Electrobun 1.18.1's compiled rcedit lookup is broken. + postBuild: "scripts/post-build.ts", postWrap: "scripts/fix-x64-headerpad.ts", + postPackage: "scripts/post-package.ts", }, release: { // Burned into every shipped bundle — the updater fetches diff --git a/apps/desktop/icon.ico b/apps/desktop/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6a57a1309c2ab5d469888a32cce745f77afc3ab5 GIT binary patch literal 79745 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�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 W# name.endsWith(".zip") && name.includes("Setup") + ); + if (!installerZip) { + throw new Error("Windows Setup ZIP was not produced."); + } + + const tempDirectory = mkdtempSync(path.join(os.tmpdir(), "llm-space-icon-")); + try { + const zipPath = path.join(artifactDirectory, installerZip); + const stagingDirectory = path.join(tempDirectory, "staging"); + _runPowerShell( + "Expand-Archive -LiteralPath $env:ZIP_PATH -DestinationPath $env:STAGING -Force", + { ZIP_PATH: zipPath, STAGING: stagingDirectory } + ); + const setupFiles = readdirSync(stagingDirectory).filter((name) => + name.endsWith(".exe") + ); + if (setupFiles.length !== 1) { + throw new Error( + `Expected one setup executable in ${installerZip}, found ${setupFiles.length}.` + ); + } + const setup = path.join(stagingDirectory, setupFiles[0]); + await rcedit(setup, { + icon: path.resolve(import.meta.dir, "..", "icon.ico"), + }); + + const repacked = path.join(tempDirectory, "installer.zip"); + _runPowerShell( + "Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::CreateFromDirectory($env:STAGING, $env:REPACKED, [IO.Compression.CompressionLevel]::Optimal, $false)", + { STAGING: stagingDirectory, REPACKED: repacked } + ); + // The system temp directory and repository may be on different drives on + // Windows, so renameSync would fail with EXDEV here. copyFileSync replaces + // the build artifact across volumes; a failure still fails the build hook. + copyFileSync(repacked, zipPath); + console.info(`Embedded Windows icon: ${installerZip}/${setupFiles[0]}`); + } finally { + rmSync(tempDirectory, { force: true, recursive: true }); + } +} + +function _runPowerShell(script: string, env: Record): void { + const result = Bun.spawnSync( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ], + { + env: { ...process.env, ...env }, + stdio: ["ignore", "inherit", "inherit"], + } + ); + if (result.exitCode !== 0) { + throw new Error(`PowerShell archive command exited with ${result.exitCode}.`); + } +} + +function _requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name} in Electrobun build hook.`); + return value; +} diff --git a/apps/desktop/scripts/smoke-test-windows.ps1 b/apps/desktop/scripts/smoke-test-windows.ps1 new file mode 100644 index 00000000..b3c92325 --- /dev/null +++ b/apps/desktop/scripts/smoke-test-windows.ps1 @@ -0,0 +1,168 @@ +[CmdletBinding()] +param( + [string]$ArtifactDirectory, + [int]$ObservationSeconds = 15, + [int]$TimeoutSeconds = 120 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "The Windows package smoke test must run on Windows." +} +if (-not $ArtifactDirectory) { + $ArtifactDirectory = Join-Path $PSScriptRoot "..\artifacts" +} + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("llm-space-smoke-" + [guid]::NewGuid().ToString("N")) +$isolatedLocalAppData = Join-Path $testRoot "local-app-data" +$isolatedAppData = Join-Path $testRoot "roaming-app-data" +$isolatedHome = Join-Path $testRoot "llm-space-home" +$expandedInstaller = Join-Path $testRoot "installer" +$oldEnvironment = @{ + LOCALAPPDATA = $env:LOCALAPPDATA + APPDATA = $env:APPDATA + LLM_SPACE_HOME = $env:LLM_SPACE_HOME +} +$launcherProcess = $null +$ownedProcessIds = [Collections.Generic.HashSet[int]]::new() +$shortcutRoots = @( + [Environment]::GetFolderPath("Desktop"), + [Environment]::GetFolderPath("StartMenu"), + [Environment]::GetFolderPath("CommonDesktopDirectory"), + [Environment]::GetFolderPath("CommonStartMenu") +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique +$existingShortcuts = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($root in $shortcutRoots) { + Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*LLM Space*.lnk" -ErrorAction SilentlyContinue | + ForEach-Object { [void]$existingShortcuts.Add($_.FullName) } +} + +function Get-TestProcesses([string]$InstallRoot) { + $root = [IO.Path]::GetFullPath($InstallRoot).TrimEnd("\") + "\" + Get-CimInstance Win32_Process | Where-Object { + ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) -or + ($_.CommandLine -and $_.CommandLine.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0) + } +} + +function Wait-ForPath([string]$Path, [int]$Seconds, [string]$Description) { + $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) + while ([DateTime]::UtcNow -lt $deadline) { + if (Test-Path -LiteralPath $Path) { return } + Start-Sleep -Milliseconds 500 + } + throw "Timed out waiting for $Description at $Path" +} + +try { + New-Item -ItemType Directory -Force -Path $isolatedLocalAppData, $isolatedAppData, $isolatedHome, $expandedInstaller | Out-Null + $env:LOCALAPPDATA = $isolatedLocalAppData + $env:APPDATA = $isolatedAppData + $env:LLM_SPACE_HOME = $isolatedHome + + $installerZip = @(Get-ChildItem -LiteralPath $ArtifactDirectory -File -Filter "*Setup*.zip") + if ($installerZip.Count -ne 1) { + throw "Expected one Windows Setup ZIP in $ArtifactDirectory, found $($installerZip.Count)." + } + Expand-Archive -LiteralPath $installerZip[0].FullName -DestinationPath $expandedInstaller -Force + $setupFiles = @(Get-ChildItem -LiteralPath $expandedInstaller -File -Filter "*.exe") + if ($setupFiles.Count -ne 1) { + throw "Expected one setup executable in $($installerZip[0].Name), found $($setupFiles.Count)." + } + $metadataFiles = @(Get-ChildItem -LiteralPath $expandedInstaller -Recurse -File -Filter "*.metadata.json") + if ($metadataFiles.Count -ne 1) { + throw "Expected one installer metadata file in $($installerZip[0].Name), found $($metadataFiles.Count)." + } + $metadata = Get-Content -LiteralPath $metadataFiles[0].FullName -Raw | ConvertFrom-Json + if (-not $metadata.identifier -or -not $metadata.channel) { + throw "Installer metadata must contain identifier and channel." + } + + Write-Host "Installing $($installerZip[0].Name) into isolated LOCALAPPDATA..." + $setupProcess = Start-Process -FilePath $setupFiles[0].FullName -Wait -PassThru -WindowStyle Hidden + if ($setupProcess.ExitCode -ne 0) { + throw "Setup.exe exited with code $($setupProcess.ExitCode)." + } + + $installRoot = Join-Path $isolatedLocalAppData "$($metadata.identifier)\$($metadata.channel)\app" + $launcher = Join-Path $installRoot "bin\launcher.exe" + $bundledBun = Join-Path $installRoot "bin\bun.exe" + $mainScript = Join-Path $installRoot "Resources\main.js" + Wait-ForPath $mainScript $TimeoutSeconds "installed Resources/main.js" + foreach ($required in @($launcher, $bundledBun, $mainScript)) { + if (-not (Test-Path -LiteralPath $required)) { + throw "Installed package is missing $required" + } + } + + Write-Host "Launching installed Windows app..." + $launcherProcess = Start-Process -FilePath $launcher -PassThru -WindowStyle Hidden + [void]$ownedProcessIds.Add($launcherProcess.Id) + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $bunProcess = $null + while ([DateTime]::UtcNow -lt $deadline) { + Get-TestProcesses $installRoot | ForEach-Object { [void]$ownedProcessIds.Add([int]$_.ProcessId) } + $launcherAlive = Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue + $bunProcess = Get-TestProcesses $installRoot | Where-Object { + $_.Name -ieq "bun.exe" -and $_.CommandLine -and $_.CommandLine.IndexOf("Resources\main.js", [StringComparison]::OrdinalIgnoreCase) -ge 0 + } | Select-Object -First 1 + if ($launcherAlive -and $bunProcess) { break } + Start-Sleep -Seconds 1 + } + if (-not $bunProcess) { + throw "The installed app did not start bun.exe with Resources/main.js." + } + if (-not (Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue)) { + throw "launcher.exe exited before the Bun main process became ready." + } + + Write-Host "Observing launcher and Bun process liveness for $ObservationSeconds seconds..." + for ($second = 0; $second -lt $ObservationSeconds; $second++) { + if (-not (Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue)) { + throw "launcher.exe exited during the observation window." + } + $bunAlive = Get-TestProcesses $installRoot | Where-Object { + $_.Name -ieq "bun.exe" -and $_.CommandLine -and $_.CommandLine.IndexOf("Resources\main.js", [StringComparison]::OrdinalIgnoreCase) -ge 0 + } | Select-Object -First 1 + if (-not $bunAlive) { + throw "bun.exe exited during the observation window." + } + [void]$ownedProcessIds.Add([int]$bunAlive.ProcessId) + Start-Sleep -Seconds 1 + } + + Write-Host "Windows package smoke test passed." +} finally { + if (Test-Path -LiteralPath $isolatedLocalAppData) { + Get-TestProcesses $isolatedLocalAppData | ForEach-Object { [void]$ownedProcessIds.Add([int]$_.ProcessId) } + } + foreach ($processId in $ownedProcessIds) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + + foreach ($root in $shortcutRoots) { + Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*LLM Space*.lnk" -ErrorAction SilentlyContinue | + Where-Object { -not $existingShortcuts.Contains($_.FullName) } | + Remove-Item -Force -ErrorAction SilentlyContinue + } + + foreach ($name in $oldEnvironment.Keys) { + $value = $oldEnvironment[$name] + if ($null -eq $value) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } else { + Set-Item "Env:$name" $value + } + } + + $resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd("\") + "\" + $resolvedTestRoot = [IO.Path]::GetFullPath($testRoot) + if ($resolvedTestRoot.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force -ErrorAction SilentlyContinue + } else { + Write-Warning "Refused to remove smoke-test path outside the system temp directory: $resolvedTestRoot" + } +} diff --git a/apps/desktop/scripts/verify-windows-package.test.ts b/apps/desktop/scripts/verify-windows-package.test.ts new file mode 100644 index 00000000..761519a1 --- /dev/null +++ b/apps/desktop/scripts/verify-windows-package.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; + +import { + verifyWindowsTar, + verifyWindowsUpdateJson, +} from "./verify-windows-package"; + +interface FixtureEntry { + name: string; + type?: string; + content?: string; +} + +const REQUIRED_ENTRIES: FixtureEntry[] = [ + { name: "LLMSpace-canary/bin/launcher.exe" }, + { name: "LLMSpace-canary/bin/bun.exe" }, + { name: "LLMSpace-canary/Resources/main.js" }, + { name: "LLMSpace-canary/Resources/app/views/mainview/index.html" }, +]; + +function _tar(entries: FixtureEntry[]): Uint8Array { + const chunks: Uint8Array[] = []; + for (const entry of entries) { + const content = new TextEncoder().encode(entry.content ?? "fixture"); + const header = new Uint8Array(512); + _writeString(header, 0, 100, entry.name); + _writeString(header, 100, 8, "0000644\0"); + _writeString(header, 108, 8, "0000000\0"); + _writeString(header, 116, 8, "0000000\0"); + _writeString(header, 124, 12, `${content.length.toString(8).padStart(11, "0")}\0`); + _writeString(header, 136, 12, "00000000000\0"); + header.fill(0x20, 148, 156); + header[156] = (entry.type ?? "0").charCodeAt(0); + _writeString(header, 257, 6, "ustar\0"); + _writeString(header, 263, 2, "00"); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + _writeString( + header, + 148, + 8, + `${checksum.toString(8).padStart(6, "0")}\0 ` + ); + chunks.push(header, content); + const padding = (512 - (content.length % 512)) % 512; + if (padding) chunks.push(new Uint8Array(padding)); + } + chunks.push(new Uint8Array(1024)); + const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const tar = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + tar.set(chunk, offset); + offset += chunk.length; + } + return tar; +} + +function _writeString( + target: Uint8Array, + offset: number, + length: number, + value: string +): void { + const encoded = new TextEncoder().encode(value); + if (encoded.length > length) throw new Error("fixture field is too long"); + target.set(encoded, offset); +} + +describe("verifyWindowsTar", () => { + test("accepts a portable archive with the required Windows payload", () => { + expect(verifyWindowsTar(_tar(REQUIRED_ENTRIES))).toHaveLength(4); + }); + + test.each(["../outside.txt", "/absolute.txt", "C:/absolute.txt"])( + "rejects unsafe archive path %s", + (name) => { + expect(() => + verifyWindowsTar(_tar([...REQUIRED_ENTRIES, { name }])) + ).toThrow("unsafe path"); + } + ); + + test("rejects GNU LongLink records with an actionable error", () => { + expect(() => + verifyWindowsTar( + _tar([ + ...REQUIRED_ENTRIES, + { name: "././@LongLink", type: "L", content: "very-long-name" }, + ]) + ) + ).toThrow("GNU LongLink"); + }); + + test("rejects an archive missing a required runtime entry", () => { + expect(() => verifyWindowsTar(_tar(REQUIRED_ENTRIES.slice(1)))).toThrow( + "bin/launcher.exe" + ); + }); +}); + +describe("verifyWindowsUpdateJson", () => { + test("accepts the Windows x64 update identity", () => { + expect( + verifyWindowsUpdateJson( + JSON.stringify({ version: "4.3.0", platform: "win", arch: "x64" }) + ) + ).toMatchObject({ platform: "win", arch: "x64" }); + }); + + test.each([ + { platform: "macos", arch: "x64" }, + { platform: "win", arch: "arm64" }, + ])("rejects update identity $platform/$arch", (identity) => { + expect(() => + verifyWindowsUpdateJson(JSON.stringify(identity)) + ).toThrow("Windows x64"); + }); +}); diff --git a/apps/desktop/scripts/verify-windows-package.ts b/apps/desktop/scripts/verify-windows-package.ts new file mode 100644 index 00000000..5d99d249 --- /dev/null +++ b/apps/desktop/scripts/verify-windows-package.ts @@ -0,0 +1,175 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { zstdDecompressSync } from "node:zlib"; + +export interface TarEntry { + name: string; + size: number; + type: string; +} + +const TAR_BLOCK_SIZE = 512; +const REQUIRED_WINDOWS_PATHS = [ + "bin/launcher.exe", + "bin/bun.exe", + "Resources/main.js", + "Resources/app/views/mainview/index.html", +] as const; + +export function verifyWindowsTar(data: Uint8Array): TarEntry[] { + const entries = _readTarEntries(data); + for (const entry of entries) { + if (entry.type === "L") { + throw new Error( + `Windows package contains a GNU LongLink record (${entry.name}). Shorten packaged paths; Electrobun's Windows extractor cannot install this archive.` + ); + } + _assertSafeArchivePath(entry.name); + } + + for (const required of REQUIRED_WINDOWS_PATHS) { + if (!entries.some((entry) => entry.name.endsWith(`/${required}`))) { + throw new Error(`Windows package is missing required entry: ${required}`); + } + } + return entries; +} + +export function verifyWindowsUpdateJson(value: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Windows update JSON is not valid JSON."); + } + if (!parsed || typeof parsed !== "object") { + throw new Error("Windows update JSON must contain an object."); + } + const update = parsed as Record; + if (update.platform !== "win" || update.arch !== "x64") { + throw new Error( + `Update metadata is not Windows x64 (platform=${String(update.platform)}, arch=${String(update.arch)}).` + ); + } + return update; +} + +export function verifyWindowsPackage(artifactDirectory: string): { + archive: string; + installer: string; + updateJson: string; + entryCount: number; +} { + const files = readdirSync(artifactDirectory); + const archive = _findSingleArtifact( + files, + (name) => name.endsWith(".tar.zst"), + "full Windows update archive" + ); + const installer = _findSingleArtifact( + files, + (name) => name.endsWith(".zip") && name.includes("Setup"), + "Windows Setup ZIP" + ); + const updateJson = _findSingleArtifact( + files, + (name) => name.endsWith("update.json"), + "Windows update JSON" + ); + + const archivePath = path.join(artifactDirectory, archive); + const tar = zstdDecompressSync(readFileSync(archivePath)); + const entries = verifyWindowsTar(tar); + verifyWindowsUpdateJson( + readFileSync(path.join(artifactDirectory, updateJson), "utf8") + ); + + const installerPath = path.join(artifactDirectory, installer); + const installerBytes = statSync(installerPath).size; + if (installerBytes < 1024 * 1024) { + throw new Error( + `Windows Setup ZIP is unexpectedly small (${installerBytes} bytes): ${installer}` + ); + } + + return { + archive: archivePath, + installer: installerPath, + updateJson: path.join(artifactDirectory, updateJson), + entryCount: entries.length, + }; +} + +function _readTarEntries(data: Uint8Array): TarEntry[] { + const entries: TarEntry[] = []; + for (let offset = 0; offset + TAR_BLOCK_SIZE <= data.length; ) { + const header = data.subarray(offset, offset + TAR_BLOCK_SIZE); + if (header.every((byte) => byte === 0)) break; + + const name = _readTarString(header, 0, 100); + const prefix = _readTarString(header, 345, 155); + const sizeText = _readTarString(header, 124, 12).trim(); + const size = sizeText ? Number.parseInt(sizeText, 8) : 0; + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error(`Windows package has an invalid tar size for ${name}.`); + } + const fullName = prefix ? `${prefix}/${name}` : name; + const typeByte = header[156]; + entries.push({ + name: fullName, + size, + type: typeByte === 0 ? "0" : String.fromCharCode(typeByte), + }); + + offset += TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + if (offset > data.length) { + throw new Error(`Windows package tar entry exceeds archive bounds: ${fullName}`); + } + } + return entries; +} + +function _readTarString( + data: Uint8Array, + offset: number, + length: number +): string { + const bytes = data.subarray(offset, offset + length); + const end = bytes.indexOf(0); + return new TextDecoder().decode(end === -1 ? bytes : bytes.subarray(0, end)); +} + +function _assertSafeArchivePath(value: string): void { + const normalized = value.replaceAll("\\", "/"); + if ( + normalized.startsWith("/") || + /^[A-Za-z]:\//.test(normalized) || + normalized.split("/").includes("..") + ) { + throw new Error(`Windows package contains an unsafe path: ${value}`); + } +} + +function _findSingleArtifact( + files: string[], + predicate: (name: string) => boolean, + description: string +): string { + const matches = files.filter(predicate); + if (matches.length !== 1) { + throw new Error( + `Expected exactly one ${description}, found ${matches.length}: ${matches.join(", ") || "none"}` + ); + } + return matches[0]; +} + +if (import.meta.main) { + const artifactDirectory = path.resolve( + process.argv[2] ?? path.join(import.meta.dir, "..", "artifacts") + ); + const result = verifyWindowsPackage(artifactDirectory); + console.info( + `Verified Windows x64 package: ${path.basename(result.installer)}; ${result.entryCount} tar entries; no GNU LongLink records.` + ); +} diff --git a/bun.lock b/bun.lock index 03b24cb4..ae53e558 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "apps/desktop": { "name": "@llm-space/desktop", - "version": "4.2.1", + "version": "4.3.0", "dependencies": { "@base-ui/react": "^1.6.0", "@codemirror/autocomplete": "6.20.3", @@ -92,6 +92,7 @@ "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", "postcss": "^8.5.3", + "rcedit": "^4.0.1", "react-scan": "^0.5.7", "tailwindcss": "^4.0.15", "typescript": "^5.7.2", diff --git a/mise.toml b/mise.toml index 8336a6bb..ada88227 100644 --- a/mise.toml +++ b/mise.toml @@ -88,6 +88,22 @@ dir = "apps/desktop" env = { LLM_SPACE_SKIP_SIGNING = "1" } run = "bun run build:canary" +[tasks."pack:win"] +description = "Build an unsigned Windows x64 regular Canary package" +dir = "apps/desktop" +env = { LLM_SPACE_SKIP_SIGNING = "1" } +run = "bun run build:canary" + +[tasks."verify:pack:win"] +description = "Verify Windows package paths, payload, and update metadata" +dir = "apps/desktop" +run = "bun run verify:pack:win" + +[tasks."smoke:win"] +description = "Install and launch the Windows package in an isolated temp root" +dir = "apps/desktop" +run = "bun run smoke:win" + [tasks."pack:perf"] description = "Unsigned Performance (CEF) canary build — the edition that embeds Chromium" dir = "apps/desktop" From 665a753e9168e3bacb29d94daee9c6c6b2a062a3 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:22:20 +0800 Subject: [PATCH 03/17] fix(windows): use platform-appropriate window behavior --- .../src/bun/app/window-options.test.ts | 21 +++++++++++++++++++ apps/desktop/src/bun/app/window-options.ts | 9 ++++++++ apps/desktop/src/bun/app/window.ts | 7 ++----- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/bun/app/window-options.test.ts create mode 100644 apps/desktop/src/bun/app/window-options.ts diff --git a/apps/desktop/src/bun/app/window-options.test.ts b/apps/desktop/src/bun/app/window-options.test.ts new file mode 100644 index 00000000..e4796b3a --- /dev/null +++ b/apps/desktop/src/bun/app/window-options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { getWindowChromeOptions } from "./window-options"; + +describe("getWindowChromeOptions", () => { + test("keeps the inset title bar and traffic lights on macOS", () => { + expect(getWindowChromeOptions("darwin")).toEqual({ + titleBarStyle: "hiddenInset", + trafficLightOffset: { x: 2, y: 16 }, + }); + }); + + test.each(["win32", "linux"] as const)( + "uses native title-bar behavior on %s", + (platform) => { + expect(getWindowChromeOptions(platform)).toEqual({ + titleBarStyle: "default", + }); + } + ); +}); diff --git a/apps/desktop/src/bun/app/window-options.ts b/apps/desktop/src/bun/app/window-options.ts new file mode 100644 index 00000000..2f41b4e7 --- /dev/null +++ b/apps/desktop/src/bun/app/window-options.ts @@ -0,0 +1,9 @@ +export function getWindowChromeOptions(platform: NodeJS.Platform) { + if (platform === "darwin") { + return { + titleBarStyle: "hiddenInset" as const, + trafficLightOffset: { x: 2, y: 16 }, + }; + } + return { titleBarStyle: "default" as const }; +} diff --git a/apps/desktop/src/bun/app/window.ts b/apps/desktop/src/bun/app/window.ts index 488fce66..94ddea65 100644 --- a/apps/desktop/src/bun/app/window.ts +++ b/apps/desktop/src/bun/app/window.ts @@ -12,6 +12,7 @@ import type { Command } from "../../shared/commands"; import type { MainWindowRPC } from "../rpc"; import { registerMenuActions } from "./menu"; +import { getWindowChromeOptions } from "./window-options"; import { attachWindowStates } from "./window-state"; const DEV_SERVER_PORT = 5173; @@ -49,12 +50,8 @@ export async function createMainWindow({ const window = new BrowserWindow({ title: "LLM Space", url, - titleBarStyle: "hiddenInset", + ...getWindowChromeOptions(process.platform), rpc, - trafficLightOffset: { - x: 2, - y: 16, - }, frame: savedFrame, }); From 5d42d0f2de4fa28e579596e018f132926c4b9a36 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:24:39 +0800 Subject: [PATCH 04/17] fix(windows): gate Bash tool on host capability --- apps/desktop/src/bun/app/start-desktop-app.ts | 2 + .../built-in/built-in-tools-module.test.ts | 2 + .../tools/built-in/built-in-tools-module.ts | 6 +++ .../desktop/src/bun/tools/built-in/fs.test.ts | 46 +++++++++++++++++++ apps/desktop/src/bun/tools/built-in/fs.ts | 42 ++++++++++++----- 5 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/bun/tools/built-in/fs.test.ts diff --git a/apps/desktop/src/bun/app/start-desktop-app.ts b/apps/desktop/src/bun/app/start-desktop-app.ts index c80a1011..29a5c32b 100644 --- a/apps/desktop/src/bun/app/start-desktop-app.ts +++ b/apps/desktop/src/bun/app/start-desktop-app.ts @@ -62,6 +62,7 @@ export async function startDesktopApp(): Promise { modules: [ createBuiltInToolsModule({ env: process.env, + bashPath: Bun.which("bash"), findSkill: skillsManager.findSkill.bind(skillsManager), getSearchSettings: searchSettings.get.bind(searchSettings), workspaceRoot: workspacePath, @@ -89,6 +90,7 @@ export async function startDesktopApp(): Promise { getRpc().send.updateStatusChanged(message) ); const commandDependencies = { + importSharedUrl: (url: string) => void deepLink?.handle(url), openExternal: Utils.openExternal, sendToWebview: (command: Command) => getRpc().send.executeCommand(command), updater, diff --git a/apps/desktop/src/bun/tools/built-in/built-in-tools-module.test.ts b/apps/desktop/src/bun/tools/built-in/built-in-tools-module.test.ts index 81ce4ca7..2dc3713a 100644 --- a/apps/desktop/src/bun/tools/built-in/built-in-tools-module.test.ts +++ b/apps/desktop/src/bun/tools/built-in/built-in-tools-module.test.ts @@ -10,6 +10,7 @@ describe("built-in tools module", () => { modules: [ createBuiltInToolsModule({ env: {}, + bashPath: "/bin/bash", findSkill: (name) => name === "fixture" ? { @@ -65,6 +66,7 @@ describe("built-in tools module", () => { modules: [ createBuiltInToolsModule({ env: {}, + bashPath: "/bin/bash", findSkill: undefined, getSearchSettings: () => ({ provider: "firecrawl", diff --git a/apps/desktop/src/bun/tools/built-in/built-in-tools-module.ts b/apps/desktop/src/bun/tools/built-in/built-in-tools-module.ts index 80607372..3de19468 100644 --- a/apps/desktop/src/bun/tools/built-in/built-in-tools-module.ts +++ b/apps/desktop/src/bun/tools/built-in/built-in-tools-module.ts @@ -41,6 +41,12 @@ function _assertDependencies( if (!dependencies.workspaceRoot) { throw new Error('Missing built-in tools dependency "workspaceRoot".'); } + if ( + dependencies.bashPath !== null && + typeof dependencies.bashPath !== "string" + ) { + throw new Error('Missing built-in tools dependency "bashPath".'); + } if (typeof dependencies.findSkill !== "function") { throw new Error('Missing built-in tools dependency "findSkill".'); } diff --git a/apps/desktop/src/bun/tools/built-in/fs.test.ts b/apps/desktop/src/bun/tools/built-in/fs.test.ts new file mode 100644 index 00000000..d3b1d6c1 --- /dev/null +++ b/apps/desktop/src/bun/tools/built-in/fs.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { bash, createFsBuiltInTools } from "./fs"; + +const BASE_DEPENDENCIES = { + findSkill: () => null, + workspaceRoot: "C:/workspace", +}; + +describe("Bash capability", () => { + test("does not advertise bash when the host cannot resolve it", () => { + expect( + createFsBuiltInTools({ ...BASE_DEPENDENCIES, bashPath: null }).map( + (entry) => entry.tool.name + ) + ).not.toContain("bash"); + }); + + test("returns an actionable error when direct execution has no Bash", () => { + expect(bash("echo hello", undefined, null)).rejects.toThrow( + "Bash is unavailable" + ); + }); + + test("executes the exact resolved path even when it contains spaces", async () => { + const calls: { command: string; args: string[]; timeout?: number }[] = []; + const result = await bash( + "printf hello", + 42, + "C:/Program Files/Git/bin/bash.exe", + (command, args, timeout) => { + calls.push({ command, args, timeout }); + return Promise.resolve({ stdout: "hello", stderr: "", code: 0 }); + } + ); + + expect(calls).toEqual([ + { + command: "C:/Program Files/Git/bin/bash.exe", + args: ["-c", "printf hello"], + timeout: 42, + }, + ]); + expect(result).toEqual({ stdout: "hello", stderr: "", exitCode: 0 }); + }); +}); diff --git a/apps/desktop/src/bun/tools/built-in/fs.ts b/apps/desktop/src/bun/tools/built-in/fs.ts index 7f30d83c..64ced2b1 100644 --- a/apps/desktop/src/bun/tools/built-in/fs.ts +++ b/apps/desktop/src/bun/tools/built-in/fs.ts @@ -12,6 +12,7 @@ import type { ToolEntry } from "../tool-registry"; export interface FsBuiltInToolsDependencies { workspaceRoot: string; findSkill: (name: string) => SkillContent | null; + bashPath: string | null; } /** @@ -597,18 +598,29 @@ const BASH_MAX_TIMEOUT_MS = 600_000; export async function bash( command: string, - timeout?: number + timeout: number | undefined, + bashPath: string | null, + runCommand: ( + command: string, + args: string[], + timeoutMs?: number + ) => Promise<{ stdout: string; stderr: string; code: number }> = _run ): Promise<{ stdout: string; stderr: string; exitCode: number; }> { + if (!bashPath) { + throw new Error( + "Bash is unavailable on this system. Install Bash (Git for Windows includes Git Bash) and add it to PATH; the structured file tools remain available." + ); + } const timeoutMs = Math.min( timeout ?? BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS ); - const { stdout, stderr, code } = await _run( - "bash", + const { stdout, stderr, code } = await runCommand( + bashPath, ["-c", command], timeoutMs ); @@ -708,6 +720,7 @@ function _isHtmlFile(filePath: string): boolean { export function createFsBuiltInTools({ workspaceRoot, findSkill, + bashPath, }: FsBuiltInToolsDependencies): ToolEntry[] { return [ { @@ -783,15 +796,20 @@ export function createFsBuiltInTools({ ); }, }, - { - tool: bashTool, - async execute(args: Record) { - return bash( - _requireString(args, "command"), - _optionalNumber(args, "timeout") - ); - }, - }, + ...(bashPath + ? [ + { + tool: bashTool, + async execute(args: Record) { + return bash( + _requireString(args, "command"), + _optionalNumber(args, "timeout"), + bashPath + ); + }, + }, + ] + : []), { tool: presentFilesTool, async execute(args: Record) { From f37b7b21e71d8db3de24c5c8d0cbb7935e7f1d4a Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:28:17 +0800 Subject: [PATCH 05/17] feat(windows): import shared links from clipboard --- apps/desktop/src/bun/commands.test.ts | 58 +++++++++++++++++++- apps/desktop/src/bun/commands.ts | 13 ++++- apps/desktop/src/bun/deep-link/index.test.ts | 24 ++++++++ apps/desktop/src/bun/deep-link/index.ts | 32 ++++++++++- apps/desktop/src/bun/import-files.ts | 9 ++- 5 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/bun/deep-link/index.test.ts diff --git a/apps/desktop/src/bun/commands.test.ts b/apps/desktop/src/bun/commands.test.ts index 1b286de4..a9ca92f5 100644 --- a/apps/desktop/src/bun/commands.test.ts +++ b/apps/desktop/src/bun/commands.test.ts @@ -1,11 +1,12 @@ import { describe, expect, mock, spyOn, test } from "bun:test"; const NATIVE_OPENED_URLS: string[] = []; +let CLIPBOARD_TEXT = ""; await mock.module("electrobun/bun", () => ({ app: { on: () => undefined }, Utils: { - clipboardReadText: () => "", + clipboardReadText: () => CLIPBOARD_TEXT, openExternal: (url: string) => NATIVE_OPENED_URLS.push(url), openFileDialog: () => Promise.resolve([]), openPath: () => undefined, @@ -15,14 +16,19 @@ await mock.module("electrobun/bun", () => ({ const { executeCommandInBun } = await import("./commands"); -function _createDependencies(openedUrls: string[]) { +function _createDependencies( + openedUrls: string[], + importedSharedUrls: string[] = [], + sentCommands: unknown[] = [] +) { return { githubAuth: { signIn: () => Promise.resolve(), signOut: () => undefined, }, + importSharedUrl: (url: string) => importedSharedUrls.push(url), openExternal: (url: string) => openedUrls.push(url), - sendToWebview: () => undefined, + sendToWebview: (command: unknown) => sentCommands.push(command), updater: { applyUpdateAndRestart: () => Promise.resolve(), checkForUpdates: () => Promise.resolve(), @@ -74,3 +80,49 @@ describe("executeCommandInBun openLink", () => { } }); }); + + +describe("executeCommandInBun importFromClipboard", () => { + test("routes an HTTPS shared-thread URL to the shared importer", () => { + const importedSharedUrls: string[] = []; + const sentCommands: unknown[] = []; + CLIPBOARD_TEXT = + " https://deer-flow.github.io/llm-space/#/shared/gist/threads/abc123 "; + + executeCommandInBun( + { type: "importFromClipboard", args: {} }, + {} as never, + _createDependencies([], importedSharedUrls, sentCommands) + ); + + expect(importedSharedUrls).toEqual([ + "https://deer-flow.github.io/llm-space/#/shared/gist/threads/abc123", + ]); + expect(sentCommands).toEqual([]); + }); + + test("keeps JSON clipboard imports on the existing file path", () => { + const importedSharedUrls: string[] = []; + const sentCommands: unknown[] = []; + CLIPBOARD_TEXT = '{"title":"Clipboard"}'; + + executeCommandInBun( + { type: "importFromClipboard", args: { parent: "imports" } }, + {} as never, + _createDependencies([], importedSharedUrls, sentCommands) + ); + + expect(importedSharedUrls).toEqual([]); + expect(sentCommands).toEqual([ + { + type: "importFiles", + args: { + parent: "imports", + files: [ + { name: "clipboard.json", text: '{"title":"Clipboard"}' }, + ], + }, + }, + ]); + }); +}); diff --git a/apps/desktop/src/bun/commands.ts b/apps/desktop/src/bun/commands.ts index 9a10a11c..d9dac827 100644 --- a/apps/desktop/src/bun/commands.ts +++ b/apps/desktop/src/bun/commands.ts @@ -8,6 +8,7 @@ import { COMMAND_META, type Command } from "../shared/commands"; import { isChineseLocale } from "./app/locales"; import { saveZoom } from "./app/window-state"; import type { GitHubAuthManager } from "./auth"; +import { parseSharedImportUrl } from "./deep-link"; import { importFilesWithNativePicker, importTextFromClipboard, @@ -33,6 +34,7 @@ const clampZoom = (zoom: number) => export interface BunCommandDependencies { githubAuth: Pick; + importSharedUrl: (url: string) => void; openExternal: (url: string) => void; sendToWebview: (command: Command) => void; updater: Pick; @@ -57,7 +59,16 @@ export function executeCommandInBun( return; } if (command.type === "importFromClipboard") { - importTextFromClipboard(dependencies.sendToWebview, command.args.parent); + const text = Utils.clipboardReadText() ?? ""; + if (parseSharedImportUrl(text)) { + dependencies.importSharedUrl(text.trim()); + } else { + importTextFromClipboard( + dependencies.sendToWebview, + command.args.parent, + text + ); + } return; } diff --git a/apps/desktop/src/bun/deep-link/index.test.ts b/apps/desktop/src/bun/deep-link/index.test.ts new file mode 100644 index 00000000..b1c704a9 --- /dev/null +++ b/apps/desktop/src/bun/deep-link/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { parseSharedImportUrl } from "./index"; + +describe("parseSharedImportUrl", () => { + test.each([ + "llm-space://shared/gist/threads/abc123", + "https://deer-flow.github.io/llm-space/#/shared/gist/threads/abc123", + ])("accepts supported shared-thread URL %s", (url) => { + expect(parseSharedImportUrl(url)).toEqual({ + connectorId: "gist", + threadId: "abc123", + }); + }); + + test.each([ + "https://example.com/llm-space/#/shared/gist/threads/abc123", + "https://deer-flow.github.io/llm-space/#/not-shared/gist/threads/abc123", + "not a URL", + "", + ])("rejects unrelated URL %s", (url) => { + expect(parseSharedImportUrl(url)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/bun/deep-link/index.ts b/apps/desktop/src/bun/deep-link/index.ts index 8a9e9766..8cc165c6 100644 --- a/apps/desktop/src/bun/deep-link/index.ts +++ b/apps/desktop/src/bun/deep-link/index.ts @@ -11,6 +11,32 @@ import type { MainWindowRPC } from "../rpc"; /** `llm-space://shared//threads/` */ const DEEP_LINK_RE = /^llm-space:\/\/shared\/([^/]+)\/threads\/([^/?#]+)/; +const SHARE_WEB_ORIGIN = "https://deer-flow.github.io"; +const SHARE_WEB_PATH = "/llm-space/"; +const SHARE_WEB_HASH_RE = /^#\/shared\/([^/]+)\/threads\/([^/?#]+)/; + +export function parseSharedImportUrl( + value: string +): { connectorId: string; threadId: string } | null { + const trimmed = value.trim(); + const deepLink = DEEP_LINK_RE.exec(trimmed); + if (deepLink) { + return { connectorId: deepLink[1], threadId: deepLink[2] }; + } + + try { + const url = new URL(trimmed); + if (url.origin !== SHARE_WEB_ORIGIN || url.pathname !== SHARE_WEB_PATH) { + return null; + } + const webLink = SHARE_WEB_HASH_RE.exec(url.hash); + return webLink + ? { connectorId: webLink[1], threadId: webLink[2] } + : null; + } catch { + return null; + } +} /** Where imported shared threads land (workspace-relative). */ const IMPORT_DIR = "shared"; @@ -51,9 +77,9 @@ export function createDeepLinkHandler({ return { async handle(url) { - const match = DEEP_LINK_RE.exec(url); - if (!match) return; // not a shared-thread deep link — ignore - const [, connectorId, threadId] = match; + const shared = parseSharedImportUrl(url); + if (!shared) return; // not a shared-thread URL — ignore + const { connectorId, threadId } = shared; const connector = connectors[connectorId]; if (!connector || !("readShared" in connector.storage)) { diff --git a/apps/desktop/src/bun/import-files.ts b/apps/desktop/src/bun/import-files.ts index 720c4be1..e2a08932 100644 --- a/apps/desktop/src/bun/import-files.ts +++ b/apps/desktop/src/bun/import-files.ts @@ -47,13 +47,16 @@ export async function importFilesWithNativePicker( * Native clipboard import entrypoint. Clipboard access belongs to the bun side; * the renderer still owns parsing/writing through the regular file-import path. */ -export function importTextFromClipboard(sendCommand: SendCommand, parent = "") { - const text = Utils.clipboardReadText(); +export function importTextFromClipboard( + sendCommand: SendCommand, + parent = "", + text = Utils.clipboardReadText() ?? "" +) { sendCommand({ type: "importFiles", args: { parent, - files: [{ name: "clipboard.json", text: text ?? "" }], + files: [{ name: "clipboard.json", text }], }, }); } From 37375219d55ae6eb0af4067edf5c5efccd806f37 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:29:30 +0800 Subject: [PATCH 06/17] fix(windows): clarify proxy environment support --- .../src/components/settings/network-page.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/settings/network-page.tsx b/apps/desktop/src/components/settings/network-page.tsx index 84749749..df297e58 100644 --- a/apps/desktop/src/components/settings/network-page.tsx +++ b/apps/desktop/src/components/settings/network-page.tsx @@ -104,8 +104,10 @@ function _hostPort(url: string | null): string | null { /** The muted "Detected: …" line under the system-proxy toggle. */ function DetectedProxy({ detection, + environmentOnly, }: { detection: SystemProxyDetection | null; + environmentOnly: boolean; }) { if (!detection) { return null; @@ -121,18 +123,22 @@ function DetectedProxy({ if (!hostPort) { return ( - No system proxy detected. + {environmentOnly + ? "No proxy environment variables detected." + : "No system proxy detected."} ); } return ( - Detected: {hostPort} (System Settings) + Detected: {hostPort}{" "} + {environmentOnly ? "(environment variables)" : "(System Settings)"} ); } export function NetworkPage() { + const environmentOnly = navigator.userAgent.includes("Windows"); const [settings, setSettings] = useState( DEFAULT_NETWORK_SETTINGS ); @@ -196,13 +202,25 @@ export function NetworkPage() {
void persist({ ...settings, useSystemProxy: next }) } /> - +
{settings.useSystemProxy ? null : ( From 50496b55cd8d3b5ce70271834673df048015253f Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:44:03 +0800 Subject: [PATCH 07/17] feat(release): publish Windows x64 canary builds --- .github/workflows/ci.yml | 25 ++++++++++++ .github/workflows/release.yml | 73 ++++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45534ad3..444281cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,28 @@ jobs: # the Pages workflow on `main`, so a change that breaks its Vite build would # otherwise stay invisible until it hit Pages. Build it on PRs too. - run: bun --filter @llm-space/web build + + windows: + runs-on: windows-2025 + timeout-minutes: 30 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + + - run: bun install --frozen-lockfile + + - run: mise run test + + - run: mise run lint + + - run: mise run typecheck + + # pack:win includes the production renderer build. Verification inspects + # the compressed tar without extracting into the checkout, then the smoke + # test installs and launches it under an isolated temporary LOCALAPPDATA. + - run: mise run pack:win + + - run: mise run verify:pack:win + + - run: mise run smoke:win diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3576bbc8..2067eab4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,8 @@ name: Release # which bumps apps/desktop/package.json, commits and tags). # A `-canary` suffix in the tag selects the canary channel; otherwise stable. # -# Jobs: meta (validate tag) → build (macOS arm64 + x64 matrix: sign, notarize, -# smoke-test) → publish. Artifacts land in two places: +# Jobs: meta (validate tag) → macOS + Windows builds (platform-native smoke +# tests) → publish. Artifacts land in two places: # - the rolling `updates` release — the machine-readable update feed the # in-app updater polls (`release.baseUrl` in electrobun.config.ts) # - a versioned release per tag — DMGs for humans (static notes) @@ -182,6 +182,9 @@ jobs: # The edition's only build-time switch: electrobun.config.ts forks the # app name, identifier and update feed off it. Empty = regular edition. LLM_SPACE_DESKTOP_RENDERER: ${{ matrix.edition == 'performance' && 'cef' || '' }} + # Keep releases from forks self-contained instead of baking the + # canonical upstream feed into their binaries. + LLM_SPACE_UPDATE_BASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ matrix.edition == 'performance' && 'updates-performance' || 'updates' }} run: mise run "build:$CHANNEL" - name: Smoke test the signed app @@ -242,8 +245,43 @@ jobs: if: always() run: security delete-keychain build.keychain || true + # Windows is a separate host-native job: the macOS matrix is intentionally + # left Apple-specific (codesign, notarization, DMG and launch checks). + build-windows: + needs: meta + runs-on: windows-2025 + timeout-minutes: 45 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ needs.meta.outputs.tag }} + + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + + - run: bun install --frozen-lockfile + + - name: Build unsigned Windows x64 regular edition + env: + CHANNEL: ${{ needs.meta.outputs.channel }} + LLM_SPACE_SKIP_SIGNING: "1" + LLM_SPACE_UPDATE_BASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/updates + run: mise run "build:$env:CHANNEL" + + - run: mise run verify:pack:win + + - run: mise run smoke:win + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # Only Canary joins the regular publish set. Stable is built and smoke + # tested for forward compatibility but remains unpublished until the + # Authenticode/Stable follow-up is complete. + name: ${{ needs.meta.outputs.channel == 'canary' && 'artifacts-regular-win-x64' || 'artifacts-windows-stable-validation' }} + path: apps/desktop/artifacts/ + if-no-files-found: error + publish: - needs: [meta, build] + needs: [meta, build, build-windows] runs-on: ubuntu-latest # Uploading two editions' worth of artifacts; the Performance tarballs are # an order of magnitude bigger than the regular ones. @@ -329,6 +367,13 @@ jobs: rename_dmgs artifacts/regular LLMSpace rename_dmgs artifacts/performance LLMSpace-performance + # Windows is Canary-only in this PR. Keep the Setup ZIP out of the + # rolling feed and give the versioned-release download a stable name. + shopt -s nullglob + for f in artifacts/regular/*-win-x64-*Setup*.zip; do + mv "$f" "artifacts/regular/LLMSpace-$VLOWER-windows-x64.zip" + done + - name: Publish versioned release env: TAG: ${{ needs.meta.outputs.tag }} @@ -348,7 +393,9 @@ jobs: INSTALL="$(cat <<'EOF' ## Install - Two editions. Install either, or both — they share the same `~/.llm-space` data, so switching keeps your threads and settings. + ### macOS + + Install either edition, or both — they share the same `~/.llm-space` data, so switching keeps your threads and settings. - **LLM Space** — uses the system WebView. Small download (~27 MB), light on memory and battery. - **LLM Space Performance** — embeds its own rendering engine (~130 MB). Rendering stays consistent across macOS versions, and usually performs better. @@ -356,6 +403,17 @@ jobs: Download a DMG below. Existing installs update in place. EOF )" + if [ "$CHANNEL" = "canary" ]; then + WINDOWS="$(cat <<'EOF' + ### Windows x64 Canary + + Download `LLMSpace-*-windows-x64.zip`, extract it, and run the included `LLM Space-Setup-canary.exe`. This early Canary is **unsigned**, so Microsoft Defender SmartScreen may warn before launch. + + Windows 10/11 x64 uses the system WebView2 runtime. There is no native ARM64 or Performance/CEF package yet, and Windows does not register the `llm-space://` URL scheme. To import a shared thread, copy its HTTPS viewer URL and choose **File → Import from Clipboard** in the app. + EOF + )" + INSTALL="$(printf '%s\n\n%s' "$INSTALL" "$WINDOWS")" + fi if [ -n "$CHANGES" ]; then NOTES="$(printf "## What's Changed\n\n%s\n\n%s\n\n**Full changelog:** https://github.com/%s/blob/%s/CHANGELOG.md\n" "$CHANGES" "$INSTALL" "$GITHUB_REPOSITORY" "$TAG")" else @@ -390,5 +448,10 @@ jobs: # "LLMSpace-v4.0.1-macos-arm64.dmg" vs # "LLMSpace-performance-v4.0.1-macos-arm64.dmg"). Only update.json is # name-less, which is why the feeds above are kept apart. - gh release upload "$TAG" artifacts/regular/*.dmg artifacts/performance/*.dmg \ + INSTALLERS=(artifacts/regular/*.dmg artifacts/performance/*.dmg) + if [ "$CHANNEL" = "canary" ]; then + shopt -s nullglob + INSTALLERS+=(artifacts/regular/*.zip) + fi + gh release upload "$TAG" "${INSTALLERS[@]}" \ --repo "$GITHUB_REPOSITORY" --clobber From 0a8d351ae11b66fe61397382aa7dc8e9470221b1 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:44:04 +0800 Subject: [PATCH 08/17] docs: document Windows x64 Canary support --- AGENTS.md | 12 ++++++++++-- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 10 +++++++++- README.zh-CN.md | 10 +++++++++- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3a6fb45..ecb6f3d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ A workbench for prompt and agent development — build, trace, debug, evaluate, | Build (stable) | `mise run build:stable` | → `vite build && electrobun build --env=stable` in `apps/desktop` | | Build the web site | `mise run build:web` | → `bun --filter @llm-space/web build` (static, `base=/llm-space/`, out to `apps/web/dist`). CI + the Pages workflow run this | | Local packaging / update test | `mise run pack` · `pack:perf` · `pack:adhoc` · `pack:signed` · `pack:feed` + `feed:serve` | env combinations over `build:canary` (skip signing / CEF Performance edition / ad-hoc sign / local update feed on :8321); defined in `mise.toml` | +| Windows x64 package gates | `mise run pack:win` · `verify:pack:win` · `smoke:win` | unsigned regular Canary build; archive/update verification; isolated install + launch smoke test (Windows host required) | | Cut a release | `mise run release` / `mise run release:canary` | → `bun scripts/release.ts`; see "Releases & auto-update" | | Test | `mise run test` | runs the complete Bun test suite from the repository root | | Lint | `mise run lint` / `mise run lint:fix` | `lint` = `eslint .` (read-only), `lint:fix` = `eslint --fix .`; flat config at repo root | @@ -128,13 +129,18 @@ State is **persisted to disk** under the llm-space root (`~/.llm-space` by defau The app version has a **single source of truth: `apps/desktop/package.json`** — `electrobun.config.ts` imports it, and release CI fails if the pushed tag doesn't match. Cut releases with `mise run release` (stable) or `mise run release:canary`; the script (`scripts/release.ts`) runs `commit-and-tag-version` (conventional-commits-driven version bump + commit + `v*` tag, config in `.versionrc.json`) and pushes atomically. Automated changelog generation is off (`skip.changelog`) — the `CHANGELOG.md` at the repo root is **hand-curated** (Keep a Changelog format), so before cutting a stable release, add a `## [x.y.z]` section for it. CI builds each versioned release's GitHub notes by extracting that version's `CHANGELOG.md` section (no `--generate-notes`, which would dump commits/authors) and appending the install blurb; prereleases with no changelog entry (canary) fall back to install-only notes. The tag triggers `.github/workflows/release.yml`: build → codesign/notarize (canary/stable only; needs the `MACOS_*`/`ASC_*` signing secrets, mapped to electrobun's `ELECTROBUN_*` env vars in the workflow) → smoke test → upload. Artifacts land in two GitHub releases: the rolling `updates` release is the machine-readable update feed (`release.baseUrl` points at it; **never delete its `.patch` files** — old installs chain through them), and a versioned release carries the DMG for humans. In-app auto-update lives in `bun/updates/` (background check → silent download → "restart to update" toast via the `updateStatusChanged` message); the dev channel never updates. -**Two editions ship from every tag.** The regular one drives the system WebView; the **Performance** edition embeds Chromium (CEF). `LLM_SPACE_DESKTOP_RENDERER=cef` is the only switch — `electrobun.config.ts` forks the app name (`LLM Space Performance`), the identifier (`…llm-space.performance`) and the update feed off it, so the two install side by side in `/Applications` and update independently. They deliberately **share `~/.llm-space`** (`getLlmSpaceHomePath()` is name-independent), so switching editions keeps threads and settings. Two things make this work and will silently break if touched: (1) each edition needs its **own rolling update release** (`updates` / `updates-performance`) — `update.json` is named `{channel}-{os}-{arch}-update.json` with no app name, so a shared release would have them overwrite each other; hence the release workflow downloads the two editions' artifacts into **separate directories** rather than `merge-multiple` into one. (2) CEF must **not** get a `remote-debugging-port` in shipped builds — `chromiumFlags` is only set when `LLM_SPACE_DESKTOP_CDP_PORT` is explicitly passed (which `dev:cef` does, and CI never does); an always-on CDP port would let any local process drive the renderer. Build the Performance edition locally with `mise run pack:perf`. +**Two macOS editions ship from every tag.** The regular one drives the system WebView; the **Performance** edition embeds Chromium (CEF). `LLM_SPACE_DESKTOP_RENDERER=cef` is the only switch — `electrobun.config.ts` forks the app name (`LLM Space Performance`), the identifier (`…llm-space.performance`) and the update feed off it, so the two install side by side in `/Applications` and update independently. They deliberately **share `~/.llm-space`** (`getLlmSpaceHomePath()` is name-independent), so switching editions keeps threads and settings. Two things make this work and will silently break if touched: (1) each edition needs its **own rolling update release** (`updates` / `updates-performance`) — `update.json` is named `{channel}-{os}-{arch}-update.json` with no app name, so a shared release would have them overwrite each other; hence the release workflow downloads the two editions' artifacts into **separate directories** rather than `merge-multiple` into one. (2) CEF must **not** get a `remote-debugging-port` in shipped builds — `chromiumFlags` is only set when `LLM_SPACE_DESKTOP_CDP_PORT` is explicitly passed (which `dev:cef` does, and CI never does); an always-on CDP port would let any local process drive the renderer. Build the Performance edition locally with `mise run pack:perf`. -Releases ship for **macOS arm64 + x64** (so four build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (wired as the `postBuild`/`postWrap` hooks): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. +Windows publication is deliberately narrower: tags are built and smoke-tested on **Windows x64 regular/WebView2**, but only the unsigned **Canary** artifact is published. It joins the regular `updates` rolling feed, while the human installer ZIP is renamed `LLMSpace-vX.Y.Z-windows-x64.zip` and uploaded only to the versioned Canary prerelease. Stable Windows publication, Authenticode, Windows ARM64, and Windows Performance/CEF remain out of scope. `apps/desktop/scripts/verify-windows-package.ts` rejects GNU `LongLink` and unsafe archive paths and checks the required payload/update metadata; `smoke-test-windows.ps1` installs under an isolated temporary `LOCALAPPDATA`, launches the real installed app, observes `launcher.exe` and the Bun main process, then removes only its owned processes, shortcuts, and temporary root. + +Windows icon handling uses the configured `build.win.icon` plus project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly. `scripts/post-build.ts` embeds `icon.ico` into `launcher.exe` and `bun.exe`; `scripts/post-package.ts` embeds it into the zipped setup executable and repacks the installer. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer is intentionally unsigned in Canary release notes and may trigger SmartScreen. + +macOS releases ship for **arm64 + x64** (so four macOS build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (invoked by `scripts/post-build.ts` at `postBuild` and directly again at `postWrap`): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. ### The command layer Every cross-boundary user action (menus, context menus, toolbar buttons, shortcuts) is a `Command` — a `type` discriminant + typed `args` — defined in `src/shared/commands.ts`. `COMMAND_META` tags each with a `target` of `"webview"` or `"bun"`. A single `executeCommand` on each side routes it: the bun side (`bun/commands.ts` `executeCommandInBun`) runs `bun`-target commands locally (window zoom/reload, open external links) and forwards `webview`-target ones over RPC; the renderer (`commands/index.tsx` `CommandProvider`) does the reverse. The native menu (`bun/app/menu.ts`) maps its string actions into commands. +On macOS, shared-thread custom URLs continue through `llm-space://shared/...`. Because Electrobun does not register that scheme on Windows, **Import from Clipboard** also recognizes the canonical `https://deer-flow.github.io/llm-space/#/shared/...` viewer URL and routes it through the same shared importer; non-URL clipboard JSON keeps the existing file-import behavior. ### App layout (`apps/desktop/src`) @@ -143,6 +149,8 @@ Every cross-boundary user action (menus, context menus, toolbar buttons, shortcu - `bun/` — main-process code: `app/` (window, menu, window-state), `rpc/`, `streaming/`, `storage/`, `models/` (`ModelManager` + builtin/custom providers), `auth/` (`GitHubAuthManager` — OAuth Device Flow + `settings/auth.json`), `fs/` (trash/reveal), `reminders/` (one-time feature reminders + GitHub-star reminder, persisted to `settings/reminders.json`; reminder definitions ship in code at `shared/feature-reminders.ts` — append to `FEATURE_REMINDERS`, never reorder or reuse an `id`), `env/hydrate` (loads login-shell env — API keys/PATH — before anything reads `process.env`), `workspace/seed`. > **GitHub calls go through the proxy.** GitHub auth (`bun/auth/`) and any future gist calls run from the **bun process** using the global `fetch`, which `NetworkSettingsManager` (`bun/network/`) routes through the user's configured proxy by writing `HTTP(S)_PROXY` onto `process.env`. Just call `fetch` — never add a bypassing custom dispatcher, or corporate/proxied users' GitHub requests will fail. +> +> **Windows capability boundaries.** Resolve Bash once at composition time with `Bun.which("bash")`; omit the Bash tool when it is unavailable instead of silently substituting PowerShell. MCP stdio remains argument-array based through the SDK's `cross-spawn` path, which handles Windows `.cmd`/`.bat`/`.exe` resolution—do not add a POSIX shell dependency. The proxy "system" path on Windows currently means `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`; Windows Internet Settings/WinHTTP are not auto-detected, and the settings UI must say so. - `client/` — renderer-side RPC callers: `rpc-transport.ts` (streaming) and `local-file-system.ts` (the `fs*` requests). - `host/` — `host-services.tsx`: the desktop `HostServices` + `ModelClient` impls (`DesktopHostProvider`, `createElectrobunModelClient`) feeding the shared `@llm-space/ui` playground. - `shared/` — code used by both contexts: `rpc.ts`, `commands.ts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e50707bc..25c7f442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to LLM Space are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Windows 10/11 x64 Canary packages using the system WebView2 runtime, with + automated archive verification and isolated install/start smoke tests. +- HTTPS shared-thread viewer URLs can be imported through **File → Import from + Clipboard**, providing a Windows-safe alternative to the unsupported custom + URL scheme. + +### Changed + +- Windows uses the native title bar so resize, snap, minimize, maximize, and + close behavior follow the operating system. +- The Bash tool is offered only when Bash is available; structured file tools + continue to work without it. +- Windows proxy settings now accurately describe the supported environment + variables and the lack of automatic Internet Settings/WinHTTP detection. + ## [4.3.0] - 2026-07-20 Prompt templates grow up: Jinja2 logic, richer variables, and in-app reminders @@ -105,6 +124,9 @@ Baseline for this changelog. See the [GitHub releases](https://github.com/deer-flow/llm-space/releases) for earlier history. +[Unreleased]: https://github.com/deer-flow/llm-space/compare/v4.3.0...HEAD +[4.3.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.3.0 +[4.2.1]: https://github.com/deer-flow/llm-space/releases/tag/v4.2.1 [4.2.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.2.0 [4.1.1]: https://github.com/deer-flow/llm-space/releases/tag/v4.1.1 [4.1.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.1.0 diff --git a/README.md b/README.md index 5dc9631d..9e011de0 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,21 @@ apps/ ## Download -Grab a DMG from the [latest release](https://github.com/deer-flow/llm-space/releases/latest) — macOS, Apple Silicon and Intel. It comes in two editions: +### macOS + +Grab a DMG from the [latest release](https://github.com/deer-flow/llm-space/releases/latest) — Apple Silicon and Intel are supported. It comes in two editions: - **LLM Space** — uses the system WebView. Small download (~27 MB), light on memory and battery. - **LLM Space Performance** — embeds its own rendering engine (~130 MB). Rendering stays consistent across macOS versions, and usually performs better. Install either, or both. They share the same `~/.llm-space` data, so switching editions keeps your threads and settings, and both update themselves in place. +### Windows x64 Canary + +Windows 10/11 x64 users can download `LLMSpace-*-windows-x64.zip` from a [Canary prerelease](https://github.com/deer-flow/llm-space/releases), extract it, and run the included `LLM Space-Setup-canary.exe`. This early Canary is unsigned, so Microsoft Defender SmartScreen may warn before launch. It uses the system WebView2 runtime; there is no Windows Performance/CEF or native ARM64 build yet. + +Known Canary limitations: Windows does not register the `llm-space://` URL scheme, WebView2 can appear soft at high display scaling, and dragging a file from Explorer may temporarily move focus away from the app. To import a shared thread, copy its HTTPS viewer URL and choose **File → Import from Clipboard**. + ## Install Building from source? You need [Bun](https://bun.com) first. Bun is a fast, all-in-one runtime and package manager for JavaScript — think of it as a drop-in replacement for Node.js and npm. Follow the [official install guide](https://bun.com/docs/installation). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2778acc8..1b798274 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,13 +60,21 @@ apps/ ## 下载 -从 [最新 release](https://github.com/deer-flow/llm-space/releases/latest) 下载 DMG —— 支持 macOS 的 Apple Silicon 和 Intel。有两个版本: +### macOS + +从 [最新 release](https://github.com/deer-flow/llm-space/releases/latest) 下载 DMG —— 支持 Apple Silicon 和 Intel。有两个版本: - **LLM Space** —— 使用系统 WebView。体积小(约 27 MB),内存和耗电更低。 - **LLM Space Performance** —— 内嵌渲染引擎(约 130 MB)。渲染在不同 macOS 版本上保持一致,性能通常更好。 装其中一个,或者两个都装。它们共享同一份 `~/.llm-space` 数据,所以切换版本后 thread 和设置都还在,两者也都会自动更新。 +### Windows x64 Canary + +Windows 10/11 x64 用户可以从 [Canary 预发布版本](https://github.com/deer-flow/llm-space/releases) 下载 `LLMSpace-*-windows-x64.zip`,解压后运行其中的 `LLM Space-Setup-canary.exe`。当前早期 Canary 尚未签名,因此 Microsoft Defender SmartScreen 可能会在启动前显示警告。该版本使用系统 WebView2 runtime;目前没有 Windows Performance/CEF 或原生 ARM64 构建。 + +Canary 已知限制:Windows 不会注册 `llm-space://` URL scheme,WebView2 在高 DPI 缩放下可能略显模糊,从 Explorer 拖入文件后应用也可能暂时失去焦点。要导入共享 thread,请复制其 HTTPS viewer URL,然后在应用中选择 **File → Import from Clipboard**。 + ## 安装 要从源码构建的话,你需要先安装 [Bun](https://bun.com)。Bun 是一个快速、一体化的 JavaScript runtime 和 package manager,可以理解为 Node.js 和 npm 的替代方案。请参考 [官方安装指南](https://bun.com/docs/installation)。 From 22e0c80841a64500ee0ef37f7e72a8feae68e318 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:40:05 +0800 Subject: [PATCH 09/17] fix(windows): hide installer and app console windows --- AGENTS.md | 6 +- CHANGELOG.md | 4 + apps/desktop/scripts/post-build.ts | 95 ++++++++-- apps/desktop/scripts/post-package.ts | 15 +- apps/desktop/scripts/smoke-test-windows.ps1 | 165 ++++++++++++++++-- .../scripts/verify-windows-package.test.ts | 23 +-- .../desktop/scripts/verify-windows-package.ts | 14 +- .../scripts/windows-executable.test.ts | 73 ++++++++ apps/desktop/scripts/windows-executable.ts | 138 +++++++++++++++ apps/desktop/scripts/windows-launcher.cs | 104 +++++++++++ bun.lock | 3 + package.json | 3 + patches/electrobun@1.18.1.patch | 6 + 13 files changed, 595 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/scripts/windows-executable.test.ts create mode 100644 apps/desktop/scripts/windows-executable.ts create mode 100644 apps/desktop/scripts/windows-launcher.cs create mode 100644 patches/electrobun@1.18.1.patch diff --git a/AGENTS.md b/AGENTS.md index ecb6f3d3..8f5e8b68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,9 +131,11 @@ The app version has a **single source of truth: `apps/desktop/package.json`** **Two macOS editions ship from every tag.** The regular one drives the system WebView; the **Performance** edition embeds Chromium (CEF). `LLM_SPACE_DESKTOP_RENDERER=cef` is the only switch — `electrobun.config.ts` forks the app name (`LLM Space Performance`), the identifier (`…llm-space.performance`) and the update feed off it, so the two install side by side in `/Applications` and update independently. They deliberately **share `~/.llm-space`** (`getLlmSpaceHomePath()` is name-independent), so switching editions keeps threads and settings. Two things make this work and will silently break if touched: (1) each edition needs its **own rolling update release** (`updates` / `updates-performance`) — `update.json` is named `{channel}-{os}-{arch}-update.json` with no app name, so a shared release would have them overwrite each other; hence the release workflow downloads the two editions' artifacts into **separate directories** rather than `merge-multiple` into one. (2) CEF must **not** get a `remote-debugging-port` in shipped builds — `chromiumFlags` is only set when `LLM_SPACE_DESKTOP_CDP_PORT` is explicitly passed (which `dev:cef` does, and CI never does); an always-on CDP port would let any local process drive the renderer. Build the Performance edition locally with `mise run pack:perf`. -Windows publication is deliberately narrower: tags are built and smoke-tested on **Windows x64 regular/WebView2**, but only the unsigned **Canary** artifact is published. It joins the regular `updates` rolling feed, while the human installer ZIP is renamed `LLMSpace-vX.Y.Z-windows-x64.zip` and uploaded only to the versioned Canary prerelease. Stable Windows publication, Authenticode, Windows ARM64, and Windows Performance/CEF remain out of scope. `apps/desktop/scripts/verify-windows-package.ts` rejects GNU `LongLink` and unsafe archive paths and checks the required payload/update metadata; `smoke-test-windows.ps1` installs under an isolated temporary `LOCALAPPDATA`, launches the real installed app, observes `launcher.exe` and the Bun main process, then removes only its owned processes, shortcuts, and temporary root. +Windows publication is deliberately narrower: tags are built and smoke-tested on **Windows x64 regular/WebView2**, but only the unsigned **Canary** artifact is published. It joins the regular `updates` rolling feed, while the human installer ZIP is renamed `LLMSpace-vX.Y.Z-windows-x64.zip` and uploaded only to the versioned Canary prerelease. Stable Windows publication, Authenticode, Windows ARM64, and Windows Performance/CEF remain out of scope. `apps/desktop/scripts/verify-windows-package.ts` rejects GNU `LongLink` and unsafe archive paths and checks the required payload/update metadata; `smoke-test-windows.ps1` installs under an isolated temporary `LOCALAPPDATA`, verifies the setup/launcher/Bun PE subsystems plus Desktop/Start Menu shortcut targets, launches the real installed app without `-WindowStyle Hidden`, asserts that only the LLM Space window is visible, then restores pre-existing shortcuts and removes only its owned processes and temporary root. -Windows icon handling uses the configured `build.win.icon` plus project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly. `scripts/post-build.ts` embeds `icon.ico` into `launcher.exe` and `bun.exe`; `scripts/post-package.ts` embeds it into the zipped setup executable and repacks the installer. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer is intentionally unsigned in Canary release notes and may trigger SmartScreen. +Windows icon and console-free launch handling use project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly and ships console-subsystem executables. `scripts/post-build.ts` renames Electrobun's original launcher to `launcher-core.exe`, compiles the small .NET Framework GUI-subsystem wrapper in `windows-launcher.cs` as `launcher.exe`, starts the core with `CreateNoWindow`, converts the private `bun.exe` to the GUI subsystem, and embeds `icon.ico` into all three binaries. The Windows build host therefore needs the standard .NET Framework 4 C# compiler under `%WINDIR%\Microsoft.NET`; GitHub's Windows runner and Windows 10/11 include it. `scripts/post-package.ts` embeds the icon into the zipped setup executable, converts Setup.exe to the GUI subsystem, and repacks the installer. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer remains unsigned and may trigger SmartScreen. + +`patches/electrobun@1.18.1.patch` binds Electrobun's internal renderer RPC server to `127.0.0.1` instead of all interfaces. This both limits the RPC listener to the local machine and prevents the Windows Firewall alert from appearing beside the app on first launch. Recheck whether the patch is still needed whenever Electrobun is upgraded; do not silently drop it. macOS releases ship for **arm64 + x64** (so four macOS build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (invoked by `scripts/post-build.ts` at `postBuild` and directly again at `postWrap`): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c7f442..a896a24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ All notable changes to LLM Space are documented here. This project follows ### Changed +- Windows installation and shortcut launches no longer open command windows; + only the installer progress and LLM Space application window remain visible. +- Electrobun's internal renderer RPC server is restricted to loopback, avoiding + a Windows Firewall prompt on first launch. - Windows uses the native title bar so resize, snap, minimize, maximize, and close behavior follow the operating system. - The Bash tool is offered only when Bash is available; structured file tools diff --git a/apps/desktop/scripts/post-build.ts b/apps/desktop/scripts/post-build.ts index 68172684..cd4ace8f 100644 --- a/apps/desktop/scripts/post-build.ts +++ b/apps/desktop/scripts/post-build.ts @@ -1,16 +1,21 @@ -import { existsSync } from "node:fs"; +import { existsSync, renameSync, rmSync } from "node:fs"; import path from "node:path"; import rcedit from "rcedit"; -const headerpad = Bun.spawnSync([ - process.execPath, - path.join(import.meta.dir, "fix-x64-headerpad.ts"), -], { - cwd: path.join(import.meta.dir, ".."), - env: process.env, - stdio: ["ignore", "inherit", "inherit"], -}); +import { + patchWindowsExecutableFileToConsole, + patchWindowsExecutableFileToGui, +} from "./windows-executable"; + +const headerpad = Bun.spawnSync( + [process.execPath, path.join(import.meta.dir, "fix-x64-headerpad.ts")], + { + cwd: path.join(import.meta.dir, ".."), + env: process.env, + stdio: ["ignore", "inherit", "inherit"], + } +); if (headerpad.exitCode !== 0) { throw new Error(`fix-x64-headerpad exited with code ${headerpad.exitCode}`); } @@ -18,18 +23,30 @@ if (headerpad.exitCode !== 0) { if (process.env.ELECTROBUN_OS === "win") { const buildDirectory = _requiredEnv("ELECTROBUN_BUILD_DIR"); const appName = _requiredEnv("ELECTROBUN_APP_NAME"); + const binDirectory = path.join(buildDirectory, appName, "bin"); const icon = path.resolve(import.meta.dir, "..", "icon.ico"); - const binaries = [ - path.join(buildDirectory, appName, "bin", "launcher.exe"), - path.join(buildDirectory, appName, "bin", "bun.exe"), - ]; - for (const binary of binaries) { + const launcher = path.join(binDirectory, "launcher.exe"); + const coreLauncher = path.join(binDirectory, "launcher-core.exe"); + const bundledBun = path.join(binDirectory, "bun.exe"); + for (const binary of [launcher, bundledBun]) { if (!existsSync(binary)) { - throw new Error(`Windows app binary not found for icon embedding: ${binary}`); + throw new Error(`Windows app binary not found: ${binary}`); } + } + + rmSync(coreLauncher, { force: true }); + renameSync(launcher, coreLauncher); + _compileWindowsLauncher(launcher, icon); + + for (const binary of [launcher, coreLauncher, bundledBun]) { await rcedit(binary, { icon }); - console.info(`Embedded Windows icon: ${path.basename(binary)}`); } + patchWindowsExecutableFileToConsole(coreLauncher); + for (const binary of [launcher, bundledBun]) { + patchWindowsExecutableFileToGui(binary); + console.info(`Configured Windows GUI subsystem: ${path.basename(binary)}`); + } + console.info("Wrapped launcher-core.exe with a console-free launcher.exe"); } function _requiredEnv(name: string): string { @@ -37,3 +54,49 @@ function _requiredEnv(name: string): string { if (!value) throw new Error(`Missing ${name} in Electrobun build hook.`); return value; } + +function _compileWindowsLauncher(output: string, icon: string): void { + const windowsDirectory = process.env.WINDIR ?? "C:\\Windows"; + const candidates = [ + path.join( + windowsDirectory, + "Microsoft.NET", + "Framework64", + "v4.0.30319", + "csc.exe" + ), + path.join( + windowsDirectory, + "Microsoft.NET", + "Framework", + "v4.0.30319", + "csc.exe" + ), + ]; + const compiler = candidates.find(existsSync); + if (!compiler) { + throw new Error( + `Windows .NET Framework C# compiler not found: ${candidates.join(", ")}` + ); + } + + const source = path.join(import.meta.dir, "windows-launcher.cs"); + const result = Bun.spawnSync( + [ + compiler, + "/nologo", + "/target:winexe", + "/platform:x64", + "/optimize+", + `/win32icon:${icon}`, + `/out:${output}`, + source, + ], + { stdio: ["ignore", "inherit", "inherit"] } + ); + if (result.exitCode !== 0 || !existsSync(output)) { + throw new Error( + `Windows console-free launcher compilation failed with ${result.exitCode}.` + ); + } +} diff --git a/apps/desktop/scripts/post-package.ts b/apps/desktop/scripts/post-package.ts index 812d78cc..8d6b37a1 100644 --- a/apps/desktop/scripts/post-package.ts +++ b/apps/desktop/scripts/post-package.ts @@ -4,6 +4,8 @@ import path from "node:path"; import rcedit from "rcedit"; +import { patchWindowsExecutableFileToGui } from "./windows-executable"; + if (process.env.ELECTROBUN_OS === "win") { const artifactDirectory = _requiredEnv("ELECTROBUN_ARTIFACT_DIR"); const [installerZip] = readdirSync(artifactDirectory).filter( @@ -33,6 +35,7 @@ if (process.env.ELECTROBUN_OS === "win") { await rcedit(setup, { icon: path.resolve(import.meta.dir, "..", "icon.ico"), }); + patchWindowsExecutableFileToGui(setup); const repacked = path.join(tempDirectory, "installer.zip"); _runPowerShell( @@ -51,20 +54,16 @@ if (process.env.ELECTROBUN_OS === "win") { function _runPowerShell(script: string, env: Record): void { const result = Bun.spawnSync( - [ - "powershell.exe", - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ], + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], { env: { ...process.env, ...env }, stdio: ["ignore", "inherit", "inherit"], } ); if (result.exitCode !== 0) { - throw new Error(`PowerShell archive command exited with ${result.exitCode}.`); + throw new Error( + `PowerShell archive command exited with ${result.exitCode}.` + ); } } diff --git a/apps/desktop/scripts/smoke-test-windows.ps1 b/apps/desktop/scripts/smoke-test-windows.ps1 index b3c92325..e1cde95e 100644 --- a/apps/desktop/scripts/smoke-test-windows.ps1 +++ b/apps/desktop/scripts/smoke-test-windows.ps1 @@ -27,16 +27,89 @@ $oldEnvironment = @{ } $launcherProcess = $null $ownedProcessIds = [Collections.Generic.HashSet[int]]::new() -$shortcutRoots = @( +$desktopShortcutRoots = @( [Environment]::GetFolderPath("Desktop"), + [Environment]::GetFolderPath("CommonDesktopDirectory") +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique +$startMenuShortcutRoots = @( [Environment]::GetFolderPath("StartMenu"), - [Environment]::GetFolderPath("CommonDesktopDirectory"), [Environment]::GetFolderPath("CommonStartMenu") ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique -$existingShortcuts = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$shortcutRoots = @($desktopShortcutRoots) + @($startMenuShortcutRoots) | Select-Object -Unique +$existingShortcutContents = @{} foreach ($root in $shortcutRoots) { Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*LLM Space*.lnk" -ErrorAction SilentlyContinue | - ForEach-Object { [void]$existingShortcuts.Add($_.FullName) } + ForEach-Object { $existingShortcutContents[$_.FullName] = [IO.File]::ReadAllBytes($_.FullName) } +} + +function Get-WindowsExecutableSubsystem([string]$Path) { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($stream.Length -lt 0x40 -or $reader.ReadUInt16() -ne 0x5A4D) { + throw "$Path is not a Windows PE image: missing DOS header." + } + + $stream.Position = 0x3C + $peOffset = $reader.ReadUInt32() + if ($peOffset + 24 -gt $stream.Length) { + throw "$Path is not a Windows PE image: missing PE header." + } + + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "$Path is not a Windows PE image: missing PE signature." + } + + $stream.Position = $peOffset + 20 + $optionalHeaderSize = $reader.ReadUInt16() + $optionalHeaderOffset = $peOffset + 24 + if ($optionalHeaderSize -lt 70 -or $optionalHeaderOffset + $optionalHeaderSize -gt $stream.Length) { + throw "$Path has a truncated Windows PE optional header." + } + + $stream.Position = $optionalHeaderOffset + $magic = $reader.ReadUInt16() + if ($magic -ne 0x10B -and $magic -ne 0x20B) { + throw ("{0} has unsupported Windows PE optional-header magic 0x{1:X}." -f $Path, $magic) + } + + $stream.Position = $optionalHeaderOffset + 68 + return $reader.ReadUInt16() + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +function Assert-WindowsGuiExecutable([string]$Path, [string]$Description) { + $subsystem = Get-WindowsExecutableSubsystem $Path + if ($subsystem -ne 2) { + throw "$Description must use the Windows GUI subsystem (2), found $subsystem at $Path." + } + Write-Host "Verified Windows GUI subsystem: $Description" +} + +function Get-Shortcuts([string[]]$Roots) { + @($Roots | ForEach-Object { + Get-ChildItem -LiteralPath $_ -Recurse -File -Filter "*LLM Space*.lnk" -ErrorAction SilentlyContinue + }) +} + +function Assert-Shortcuts([string[]]$Roots, [string]$Description, [string]$ExpectedTarget) { + $shell = New-Object -ComObject WScript.Shell + try { + $matching = @(Get-Shortcuts $Roots | Where-Object { + $shortcut = $shell.CreateShortcut($_.FullName) + $shortcut.TargetPath.Equals($ExpectedTarget, [StringComparison]::OrdinalIgnoreCase) + }) + if ($matching.Count -lt 1) { + throw "The installer did not create an LLM Space $Description shortcut targeting $ExpectedTarget." + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) + } + Write-Host "Verified $Description shortcut: $($matching[0].FullName)" } function Get-TestProcesses([string]$InstallRoot) { @@ -47,6 +120,25 @@ function Get-TestProcesses([string]$InstallRoot) { } } +function Get-DescendantProcesses([int]$RootProcessId) { + $allProcesses = @(Get-CimInstance Win32_Process) + $knownIds = [Collections.Generic.HashSet[int]]::new() + [void]$knownIds.Add($RootProcessId) + $descendants = [Collections.Generic.List[object]]::new() + $added = $true + while ($added) { + $added = $false + foreach ($process in $allProcesses) { + if (-not $knownIds.Contains([int]$process.ProcessId) -and $knownIds.Contains([int]$process.ParentProcessId)) { + [void]$knownIds.Add([int]$process.ProcessId) + [void]$descendants.Add($process) + $added = $true + } + } + } + $descendants +} + function Wait-ForPath([string]$Path, [int]$Seconds, [string]$Description) { $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) while ([DateTime]::UtcNow -lt $deadline) { @@ -80,49 +172,81 @@ try { throw "Installer metadata must contain identifier and channel." } + Assert-WindowsGuiExecutable $setupFiles[0].FullName "Setup.exe" Write-Host "Installing $($installerZip[0].Name) into isolated LOCALAPPDATA..." - $setupProcess = Start-Process -FilePath $setupFiles[0].FullName -Wait -PassThru -WindowStyle Hidden + $setupProcess = Start-Process -FilePath $setupFiles[0].FullName -Wait -PassThru if ($setupProcess.ExitCode -ne 0) { throw "Setup.exe exited with code $($setupProcess.ExitCode)." } $installRoot = Join-Path $isolatedLocalAppData "$($metadata.identifier)\$($metadata.channel)\app" $launcher = Join-Path $installRoot "bin\launcher.exe" + $coreLauncher = Join-Path $installRoot "bin\launcher-core.exe" $bundledBun = Join-Path $installRoot "bin\bun.exe" $mainScript = Join-Path $installRoot "Resources\main.js" Wait-ForPath $mainScript $TimeoutSeconds "installed Resources/main.js" - foreach ($required in @($launcher, $bundledBun, $mainScript)) { + foreach ($required in @($launcher, $coreLauncher, $bundledBun, $mainScript)) { if (-not (Test-Path -LiteralPath $required)) { throw "Installed package is missing $required" } } + Assert-WindowsGuiExecutable $launcher "installed launcher.exe wrapper" + Assert-WindowsGuiExecutable $bundledBun "installed bun.exe" + $coreSubsystem = Get-WindowsExecutableSubsystem $coreLauncher + if ($coreSubsystem -ne 3) { + throw "installed launcher-core.exe must remain a console image behind the hidden wrapper; found subsystem $coreSubsystem." + } + Assert-Shortcuts $desktopShortcutRoots "desktop" $launcher + Assert-Shortcuts $startMenuShortcutRoots "Start Menu" $launcher Write-Host "Launching installed Windows app..." - $launcherProcess = Start-Process -FilePath $launcher -PassThru -WindowStyle Hidden + $launcherProcess = Start-Process -FilePath $launcher -PassThru [void]$ownedProcessIds.Add($launcherProcess.Id) $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $coreProcess = $null $bunProcess = $null while ([DateTime]::UtcNow -lt $deadline) { Get-TestProcesses $installRoot | ForEach-Object { [void]$ownedProcessIds.Add([int]$_.ProcessId) } - $launcherAlive = Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue + $wrapperAlive = Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue + $coreProcess = Get-TestProcesses $installRoot | Where-Object { + $_.Name -ieq "launcher-core.exe" + } | Select-Object -First 1 $bunProcess = Get-TestProcesses $installRoot | Where-Object { $_.Name -ieq "bun.exe" -and $_.CommandLine -and $_.CommandLine.IndexOf("Resources\main.js", [StringComparison]::OrdinalIgnoreCase) -ge 0 } | Select-Object -First 1 - if ($launcherAlive -and $bunProcess) { break } + if ($wrapperAlive -and $coreProcess -and $bunProcess) { break } Start-Sleep -Seconds 1 } + if (-not $coreProcess) { + throw "The console-free wrapper did not start launcher-core.exe." + } if (-not $bunProcess) { throw "The installed app did not start bun.exe with Resources/main.js." } if (-not (Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue)) { - throw "launcher.exe exited before the Bun main process became ready." + throw "launcher.exe wrapper exited before the app became ready." } - Write-Host "Observing launcher and Bun process liveness for $ObservationSeconds seconds..." + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $mainWindow = $null + while ([DateTime]::UtcNow -lt $deadline) { + $mainWindow = Get-Process -Id ([int]$bunProcess.ProcessId) -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq "LLM Space" } + if ($mainWindow) { break } + Start-Sleep -Milliseconds 500 + } + if (-not $mainWindow) { + throw "The installed app did not create the LLM Space main window." + } + + Write-Host "Observing the single app window and process liveness for $ObservationSeconds seconds..." for ($second = 0; $second -lt $ObservationSeconds; $second++) { if (-not (Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue)) { - throw "launcher.exe exited during the observation window." + throw "launcher.exe wrapper exited during the observation window." + } + if (-not (Get-Process -Id ([int]$coreProcess.ProcessId) -ErrorAction SilentlyContinue)) { + throw "launcher-core.exe exited during the observation window." } $bunAlive = Get-TestProcesses $installRoot | Where-Object { $_.Name -ieq "bun.exe" -and $_.CommandLine -and $_.CommandLine.IndexOf("Resources\main.js", [StringComparison]::OrdinalIgnoreCase) -ge 0 @@ -131,6 +255,17 @@ try { throw "bun.exe exited during the observation window." } [void]$ownedProcessIds.Add([int]$bunAlive.ProcessId) + $descendants = @(Get-DescendantProcesses $launcherProcess.Id) + $descendants | ForEach-Object { [void]$ownedProcessIds.Add([int]$_.ProcessId) } + $visibleAppProcesses = @($descendants | ForEach-Object { + Get-Process -Id ([int]$_.ProcessId) -ErrorAction SilentlyContinue + } | Where-Object { $_.MainWindowHandle -ne 0 }) + $unexpectedWindows = @($visibleAppProcesses | Where-Object { $_.MainWindowTitle -ne "LLM Space" }) + $mainWindows = @($visibleAppProcesses | Where-Object { $_.MainWindowTitle -eq "LLM Space" }) + if ($unexpectedWindows.Count -gt 0 -or $mainWindows.Count -ne 1) { + $titles = @($visibleAppProcesses | ForEach-Object { "$($_.ProcessName): $($_.MainWindowTitle)" }) -join "; " + throw "Expected only the LLM Space main window; visible related windows: $titles" + } Start-Sleep -Seconds 1 } @@ -145,10 +280,14 @@ try { foreach ($root in $shortcutRoots) { Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*LLM Space*.lnk" -ErrorAction SilentlyContinue | - Where-Object { -not $existingShortcuts.Contains($_.FullName) } | + Where-Object { -not $existingShortcutContents.ContainsKey($_.FullName) } | Remove-Item -Force -ErrorAction SilentlyContinue } + foreach ($entry in $existingShortcutContents.GetEnumerator()) { + [IO.File]::WriteAllBytes($entry.Key, $entry.Value) + } + foreach ($name in $oldEnvironment.Keys) { $value = $oldEnvironment[$name] if ($null -eq $value) { diff --git a/apps/desktop/scripts/verify-windows-package.test.ts b/apps/desktop/scripts/verify-windows-package.test.ts index 761519a1..f15dc8aa 100644 --- a/apps/desktop/scripts/verify-windows-package.test.ts +++ b/apps/desktop/scripts/verify-windows-package.test.ts @@ -13,6 +13,7 @@ interface FixtureEntry { const REQUIRED_ENTRIES: FixtureEntry[] = [ { name: "LLMSpace-canary/bin/launcher.exe" }, + { name: "LLMSpace-canary/bin/launcher-core.exe" }, { name: "LLMSpace-canary/bin/bun.exe" }, { name: "LLMSpace-canary/Resources/main.js" }, { name: "LLMSpace-canary/Resources/app/views/mainview/index.html" }, @@ -27,19 +28,19 @@ function _tar(entries: FixtureEntry[]): Uint8Array { _writeString(header, 100, 8, "0000644\0"); _writeString(header, 108, 8, "0000000\0"); _writeString(header, 116, 8, "0000000\0"); - _writeString(header, 124, 12, `${content.length.toString(8).padStart(11, "0")}\0`); + _writeString( + header, + 124, + 12, + `${content.length.toString(8).padStart(11, "0")}\0` + ); _writeString(header, 136, 12, "00000000000\0"); header.fill(0x20, 148, 156); header[156] = (entry.type ?? "0").charCodeAt(0); _writeString(header, 257, 6, "ustar\0"); _writeString(header, 263, 2, "00"); const checksum = header.reduce((sum, byte) => sum + byte, 0); - _writeString( - header, - 148, - 8, - `${checksum.toString(8).padStart(6, "0")}\0 ` - ); + _writeString(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `); chunks.push(header, content); const padding = (512 - (content.length % 512)) % 512; if (padding) chunks.push(new Uint8Array(padding)); @@ -68,7 +69,7 @@ function _writeString( describe("verifyWindowsTar", () => { test("accepts a portable archive with the required Windows payload", () => { - expect(verifyWindowsTar(_tar(REQUIRED_ENTRIES))).toHaveLength(4); + expect(verifyWindowsTar(_tar(REQUIRED_ENTRIES))).toHaveLength(5); }); test.each(["../outside.txt", "/absolute.txt", "C:/absolute.txt"])( @@ -111,8 +112,8 @@ describe("verifyWindowsUpdateJson", () => { { platform: "macos", arch: "x64" }, { platform: "win", arch: "arm64" }, ])("rejects update identity $platform/$arch", (identity) => { - expect(() => - verifyWindowsUpdateJson(JSON.stringify(identity)) - ).toThrow("Windows x64"); + expect(() => verifyWindowsUpdateJson(JSON.stringify(identity))).toThrow( + "Windows x64" + ); }); }); diff --git a/apps/desktop/scripts/verify-windows-package.ts b/apps/desktop/scripts/verify-windows-package.ts index 5d99d249..ac91f3c9 100644 --- a/apps/desktop/scripts/verify-windows-package.ts +++ b/apps/desktop/scripts/verify-windows-package.ts @@ -11,6 +11,7 @@ export interface TarEntry { const TAR_BLOCK_SIZE = 512; const REQUIRED_WINDOWS_PATHS = [ "bin/launcher.exe", + "bin/launcher-core.exe", "bin/bun.exe", "Resources/main.js", "Resources/app/views/mainview/index.html", @@ -35,7 +36,9 @@ export function verifyWindowsTar(data: Uint8Array): TarEntry[] { return entries; } -export function verifyWindowsUpdateJson(value: string): Record { +export function verifyWindowsUpdateJson( + value: string +): Record { let parsed: unknown; try { parsed = JSON.parse(value); @@ -102,7 +105,7 @@ export function verifyWindowsPackage(artifactDirectory: string): { function _readTarEntries(data: Uint8Array): TarEntry[] { const entries: TarEntry[] = []; - for (let offset = 0; offset + TAR_BLOCK_SIZE <= data.length; ) { + for (let offset = 0; offset + TAR_BLOCK_SIZE <= data.length;) { const header = data.subarray(offset, offset + TAR_BLOCK_SIZE); if (header.every((byte) => byte === 0)) break; @@ -121,9 +124,12 @@ function _readTarEntries(data: Uint8Array): TarEntry[] { type: typeByte === 0 ? "0" : String.fromCharCode(typeByte), }); - offset += TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + offset += + TAR_BLOCK_SIZE + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; if (offset > data.length) { - throw new Error(`Windows package tar entry exceeds archive bounds: ${fullName}`); + throw new Error( + `Windows package tar entry exceeds archive bounds: ${fullName}` + ); } } return entries; diff --git a/apps/desktop/scripts/windows-executable.test.ts b/apps/desktop/scripts/windows-executable.test.ts new file mode 100644 index 00000000..0613ac6e --- /dev/null +++ b/apps/desktop/scripts/windows-executable.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; + +import { + getWindowsExecutableSubsystem, + patchWindowsExecutableToConsole, + patchWindowsExecutableToGui, +} from "./windows-executable"; + +const PE_OFFSET = 0x80; +const OPTIONAL_HEADER_SIZE = 0xf0; +const SUBSYSTEM_OFFSET = PE_OFFSET + 4 + 20 + 68; + +function _makePeImage(subsystem: number): Uint8Array { + const image = new Uint8Array(PE_OFFSET + 4 + 20 + OPTIONAL_HEADER_SIZE); + image[0] = 0x4d; + image[1] = 0x5a; + new DataView(image.buffer).setUint32(0x3c, PE_OFFSET, true); + image.set([0x50, 0x45, 0, 0], PE_OFFSET); + + const coffOffset = PE_OFFSET + 4; + new DataView(image.buffer).setUint16(coffOffset, 0x8664, true); + new DataView(image.buffer).setUint16( + coffOffset + 16, + OPTIONAL_HEADER_SIZE, + true + ); + + const optionalOffset = coffOffset + 20; + new DataView(image.buffer).setUint16(optionalOffset, 0x20b, true); + new DataView(image.buffer).setUint16(SUBSYSTEM_OFFSET, subsystem, true); + return image; +} + +describe("Windows PE subsystem helper", () => { + test("reads and converts a console PE image to GUI", () => { + const image = _makePeImage(3); + + expect(getWindowsExecutableSubsystem(image)).toBe(3); + const patched = patchWindowsExecutableToGui(image); + + expect(getWindowsExecutableSubsystem(patched)).toBe(2); + expect(getWindowsExecutableSubsystem(image)).toBe(3); + }); + + test("restores a GUI PE image to the console subsystem", () => { + const image = _makePeImage(2); + + const patched = patchWindowsExecutableToConsole(image); + + expect(getWindowsExecutableSubsystem(patched)).toBe(3); + expect(getWindowsExecutableSubsystem(image)).toBe(2); + }); + + test("is idempotent for an existing GUI image", () => { + const image = _makePeImage(2); + + expect( + getWindowsExecutableSubsystem(patchWindowsExecutableToGui(image)) + ).toBe(2); + }); + + test("rejects an unsupported subsystem", () => { + expect(() => patchWindowsExecutableToGui(_makePeImage(9))).toThrow( + "Unsupported Windows PE subsystem 9" + ); + }); + + test("rejects a non-PE file", () => { + expect(() => getWindowsExecutableSubsystem(new Uint8Array(128))).toThrow( + "missing DOS header" + ); + }); +}); diff --git a/apps/desktop/scripts/windows-executable.ts b/apps/desktop/scripts/windows-executable.ts new file mode 100644 index 00000000..6e566756 --- /dev/null +++ b/apps/desktop/scripts/windows-executable.ts @@ -0,0 +1,138 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const DOS_HEADER_SIZE = 0x40; +const PE_SIGNATURE_SIZE = 4; +const COFF_HEADER_SIZE = 20; +const OPTIONAL_HEADER_SUBSYSTEM_OFFSET = 68; +const PE32_MAGIC = 0x10b; +const PE32_PLUS_MAGIC = 0x20b; +const WINDOWS_GUI_SUBSYSTEM = 2; +const WINDOWS_CONSOLE_SUBSYSTEM = 3; + +/** + * Return the PE subsystem value from a Windows executable image. + * + * This intentionally handles only PE32/PE32+ images and validates the header + * boundaries before touching the subsystem field. The Windows build currently + * targets x64, but accepting PE32 here keeps the helper useful for the setup + * extractor if Electrobun changes its architecture in the future. + */ +export function getWindowsExecutableSubsystem(data: Uint8Array): number { + const subsystemOffset = _getSubsystemOffset(data); + return _readUInt16(data, subsystemOffset); +} + +/** + * Make a Windows executable a GUI-subsystem image so Explorer does not create + * a console window when it is double-clicked. The image entry point and code + * are unchanged; only the PE subsystem metadata is updated. + */ +export function patchWindowsExecutableToGui(data: Uint8Array): Uint8Array { + return _patchWindowsExecutableSubsystem(data, WINDOWS_GUI_SUBSYSTEM); +} + +/** Restore a Windows executable to the console subsystem. */ +export function patchWindowsExecutableToConsole(data: Uint8Array): Uint8Array { + return _patchWindowsExecutableSubsystem(data, WINDOWS_CONSOLE_SUBSYSTEM); +} + +/** Patch a Windows executable file in place and return its previous subsystem. */ +export function patchWindowsExecutableFileToGui(filePath: string): number { + return _patchWindowsExecutableFile(filePath, WINDOWS_GUI_SUBSYSTEM); +} + +/** Restore a Windows executable file to the console subsystem in place. */ +export function patchWindowsExecutableFileToConsole(filePath: string): number { + return _patchWindowsExecutableFile(filePath, WINDOWS_CONSOLE_SUBSYSTEM); +} + +function _patchWindowsExecutableFile( + filePath: string, + subsystem: number +): number { + const original = readFileSync(filePath); + const previous = getWindowsExecutableSubsystem(original); + const patched = _patchWindowsExecutableSubsystem(original, subsystem); + writeFileSync(filePath, patched); + return previous; +} + +function _patchWindowsExecutableSubsystem( + data: Uint8Array, + subsystem: number +): Uint8Array { + const patched = new Uint8Array(data); + const subsystemOffset = _getSubsystemOffset(patched); + const current = _readUInt16(patched, subsystemOffset); + if ( + current !== WINDOWS_CONSOLE_SUBSYSTEM && + current !== WINDOWS_GUI_SUBSYSTEM + ) { + throw new Error( + `Unsupported Windows PE subsystem ${current}; expected console (${WINDOWS_CONSOLE_SUBSYSTEM}) or GUI (${WINDOWS_GUI_SUBSYSTEM}).` + ); + } + _writeUInt16(patched, subsystemOffset, subsystem); + return patched; +} + +function _getSubsystemOffset(data: Uint8Array): number { + if (data.length < DOS_HEADER_SIZE || _readUInt16(data, 0) !== 0x5a4d) { + throw new Error("Not a Windows PE image: missing DOS header."); + } + + const peOffset = _readUInt32(data, 0x3c); + const peHeaderOffset = peOffset + PE_SIGNATURE_SIZE; + if ( + peHeaderOffset + COFF_HEADER_SIZE > data.length || + !_hasPeSignature(data, peOffset) + ) { + throw new Error("Not a Windows PE image: missing PE signature."); + } + + const optionalHeaderSize = _readUInt16(data, peOffset + 4 + 16); + const optionalHeaderOffset = peOffset + PE_SIGNATURE_SIZE + COFF_HEADER_SIZE; + if ( + optionalHeaderSize < OPTIONAL_HEADER_SUBSYSTEM_OFFSET + 2 || + optionalHeaderOffset + optionalHeaderSize > data.length + ) { + throw new Error("Windows PE optional header is truncated."); + } + + const magic = _readUInt16(data, optionalHeaderOffset); + if (magic !== PE32_MAGIC && magic !== PE32_PLUS_MAGIC) { + throw new Error( + `Unsupported Windows PE optional-header magic: 0x${magic.toString(16)}.` + ); + } + return optionalHeaderOffset + OPTIONAL_HEADER_SUBSYSTEM_OFFSET; +} + +function _hasPeSignature(data: Uint8Array, offset: number): boolean { + return ( + offset + PE_SIGNATURE_SIZE <= data.length && + data[offset] === 0x50 && + data[offset + 1] === 0x45 && + data[offset + 2] === 0 && + data[offset + 3] === 0 + ); +} + +function _readUInt16(data: Uint8Array, offset: number): number { + return data[offset] | (data[offset + 1] << 8); +} + +function _readUInt32(data: Uint8Array, offset: number): number { + return ( + (data[offset] | + (data[offset + 1] << 8) | + (data[offset + 2] << 16) | + (data[offset + 3] << 24)) >>> + 0 + ); +} + +function _writeUInt16(data: Uint8Array, offset: number, value: number): void { + data[offset] = value & 0xff; + data[offset + 1] = value >>> 8; +} diff --git a/apps/desktop/scripts/windows-launcher.cs b/apps/desktop/scripts/windows-launcher.cs new file mode 100644 index 00000000..3a405c78 --- /dev/null +++ b/apps/desktop/scripts/windows-launcher.cs @@ -0,0 +1,104 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class WindowsLauncher +{ + private const string CoreLauncherName = "launcher-core.exe"; + + [STAThread] + private static int Main(string[] args) + { + string directory = AppDomain.CurrentDomain.BaseDirectory; + string coreLauncher = Path.Combine(directory, CoreLauncherName); + if (!File.Exists(coreLauncher)) + { + return 2; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = coreLauncher, + Arguments = BuildArguments(args), + WorkingDirectory = directory, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using (Process process = new Process { StartInfo = startInfo }) + { + process.OutputDataReceived += IgnoreOutput; + process.ErrorDataReceived += IgnoreOutput; + if (!process.Start()) + { + return 3; + } + + process.StandardInput.Close(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + return process.ExitCode; + } + } + + private static void IgnoreOutput(object sender, DataReceivedEventArgs args) + { + } + + private static string BuildArguments(string[] args) + { + StringBuilder commandLine = new StringBuilder(); + foreach (string arg in args) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(arg)); + } + return commandLine.ToString(); + } + + // Recreate a Windows command-line argument according to the escaping rules + // used by CommandLineToArgvW so deep links and file paths survive unchanged. + private static string QuoteArgument(string value) + { + if (value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) < 0) + { + return value; + } + + StringBuilder quoted = new StringBuilder(); + quoted.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + quoted.Append('\\', backslashes * 2 + 1); + quoted.Append('"'); + backslashes = 0; + continue; + } + + quoted.Append('\\', backslashes); + backslashes = 0; + quoted.Append(character); + } + quoted.Append('\\', backslashes * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} diff --git a/bun.lock b/bun.lock index ae53e558..db81693b 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,9 @@ }, }, }, + "patchedDependencies": { + "electrobun@1.18.1": "patches/electrobun@1.18.1.patch", + }, "overrides": { "@codemirror/state": "6.7.0", "@codemirror/view": "6.43.5", diff --git a/package.json b/package.json index f97b8ca4..73c043b5 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,8 @@ "react": "catalog:", "react-dom": "catalog:", "typebox": "catalog:" + }, + "patchedDependencies": { + "electrobun@1.18.1": "patches/electrobun@1.18.1.patch" } } diff --git a/patches/electrobun@1.18.1.patch b/patches/electrobun@1.18.1.patch new file mode 100644 index 00000000..5b6e5f6e --- /dev/null +++ b/patches/electrobun@1.18.1.patch @@ -0,0 +1,6 @@ +diff --git a/dist/api/bun/core/Socket.ts b/dist/api/bun/core/Socket.ts +index cdaa2bca0fabe577cb667056aa6f8f84211189c2..2d291f7dd7b97a644d40f2d105b0e7028387b0ce 100644 +--- a/dist/api/bun/core/Socket.ts ++++ b/dist/api/bun/core/Socket.ts +@@ -67,0 +68 @@ const startRPCServer = () => { ++ hostname: "127.0.0.1", From 5b25103f9d0f3db771c470af97129471dda1a0bd Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:48:19 +0800 Subject: [PATCH 10/17] fix(windows): polish installer and native window icon --- AGENTS.md | 4 +- CHANGELOG.md | 7 +- apps/desktop/scripts/post-build.ts | 53 +--- apps/desktop/scripts/post-package.ts | 35 ++- apps/desktop/scripts/smoke-test-windows.ps1 | 69 ++++- apps/desktop/scripts/windows-csharp.ts | 67 +++++ .../scripts/windows-executable.test.ts | 73 ----- apps/desktop/scripts/windows-installer.cs | 281 ++++++++++++++++++ patches/electrobun@1.18.1.patch | 62 +++- 9 files changed, 518 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/scripts/windows-csharp.ts delete mode 100644 apps/desktop/scripts/windows-executable.test.ts create mode 100644 apps/desktop/scripts/windows-installer.cs diff --git a/AGENTS.md b/AGENTS.md index 8f5e8b68..4d8af99a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,9 @@ The app version has a **single source of truth: `apps/desktop/package.json`** Windows publication is deliberately narrower: tags are built and smoke-tested on **Windows x64 regular/WebView2**, but only the unsigned **Canary** artifact is published. It joins the regular `updates` rolling feed, while the human installer ZIP is renamed `LLMSpace-vX.Y.Z-windows-x64.zip` and uploaded only to the versioned Canary prerelease. Stable Windows publication, Authenticode, Windows ARM64, and Windows Performance/CEF remain out of scope. `apps/desktop/scripts/verify-windows-package.ts` rejects GNU `LongLink` and unsafe archive paths and checks the required payload/update metadata; `smoke-test-windows.ps1` installs under an isolated temporary `LOCALAPPDATA`, verifies the setup/launcher/Bun PE subsystems plus Desktop/Start Menu shortcut targets, launches the real installed app without `-WindowStyle Hidden`, asserts that only the LLM Space window is visible, then restores pre-existing shortcuts and removes only its owned processes and temporary root. -Windows icon and console-free launch handling use project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly and ships console-subsystem executables. `scripts/post-build.ts` renames Electrobun's original launcher to `launcher-core.exe`, compiles the small .NET Framework GUI-subsystem wrapper in `windows-launcher.cs` as `launcher.exe`, starts the core with `CreateNoWindow`, converts the private `bun.exe` to the GUI subsystem, and embeds `icon.ico` into all three binaries. The Windows build host therefore needs the standard .NET Framework 4 C# compiler under `%WINDIR%\Microsoft.NET`; GitHub's Windows runner and Windows 10/11 include it. `scripts/post-package.ts` embeds the icon into the zipped setup executable, converts Setup.exe to the GUI subsystem, and repacks the installer. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer remains unsigned and may trigger SmartScreen. +Windows icon and console-free launch handling use project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly and ships console-subsystem executables. `scripts/post-build.ts` renames Electrobun's original launcher to `launcher-core.exe`, compiles the small .NET Framework GUI-subsystem wrapper in `windows-launcher.cs` as `launcher.exe`, starts the core with `CreateNoWindow`, converts the private `bun.exe` to the GUI subsystem, and embeds `icon.ico` into all three binaries. `scripts/post-package.ts` compiles `windows-installer.cs` as the visible native progress window, embeds Electrobun's original extractor plus its metadata/archive payload, and runs that extractor with `CreateNoWindow`; Setup remains self-contained and the ZIP still exposes only one executable. The shared compiler helper is `windows-csharp.ts`. The Windows build host therefore needs the standard .NET Framework 4 C# compiler under `%WINDIR%\Microsoft.NET`; GitHub's Windows runner and Windows 10/11 include it. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer remains unsigned and may trigger SmartScreen. -`patches/electrobun@1.18.1.patch` binds Electrobun's internal renderer RPC server to `127.0.0.1` instead of all interfaces. This both limits the RPC listener to the local machine and prevents the Windows Firewall alert from appearing beside the app on first launch. Recheck whether the patch is still needed whenever Electrobun is upgraded; do not silently drop it. +`patches/electrobun@1.18.1.patch` changes behavior only when `process.platform === "win32"`: it binds Electrobun's internal renderer RPC server to `127.0.0.1` to avoid the Windows Firewall alert, and uses `user32.dll` to put the bundled static `app.ico` on the native window because Electrobun's Windows `setWindowIcon` implementation is a no-op. The macOS socket behavior and window creation path remain unchanged. Keep these platform guards intact, and recheck whether the patch is still needed whenever Electrobun is upgraded. macOS releases ship for **arm64 + x64** (so four macOS build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (invoked by `scripts/post-build.ts` at `postBuild` and directly again at `postWrap`): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index a896a24c..9d371ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,11 @@ All notable changes to LLM Space are documented here. This project follows - Windows installation and shortcut launches no longer open command windows; only the installer progress and LLM Space application window remain visible. -- Electrobun's internal renderer RPC server is restricted to loopback, avoiding - a Windows Firewall prompt on first launch. +- On Windows, Electrobun's internal renderer RPC server is restricted to + loopback, avoiding a Windows Firewall prompt on first launch. - Windows uses the native title bar so resize, snap, minimize, maximize, and - close behavior follow the operating system. + close behavior follow the operating system, and the title bar now displays + the bundled LLM Space icon. - The Bash tool is offered only when Bash is available; structured file tools continue to work without it. - Windows proxy settings now accurately describe the supported environment diff --git a/apps/desktop/scripts/post-build.ts b/apps/desktop/scripts/post-build.ts index cd4ace8f..93795acd 100644 --- a/apps/desktop/scripts/post-build.ts +++ b/apps/desktop/scripts/post-build.ts @@ -3,6 +3,7 @@ import path from "node:path"; import rcedit from "rcedit"; +import { compileWindowsGuiExecutable } from "./windows-csharp"; import { patchWindowsExecutableFileToConsole, patchWindowsExecutableFileToGui, @@ -36,7 +37,11 @@ if (process.env.ELECTROBUN_OS === "win") { rmSync(coreLauncher, { force: true }); renameSync(launcher, coreLauncher); - _compileWindowsLauncher(launcher, icon); + compileWindowsGuiExecutable({ + source: path.join(import.meta.dir, "windows-launcher.cs"), + output: launcher, + icon, + }); for (const binary of [launcher, coreLauncher, bundledBun]) { await rcedit(binary, { icon }); @@ -54,49 +59,3 @@ function _requiredEnv(name: string): string { if (!value) throw new Error(`Missing ${name} in Electrobun build hook.`); return value; } - -function _compileWindowsLauncher(output: string, icon: string): void { - const windowsDirectory = process.env.WINDIR ?? "C:\\Windows"; - const candidates = [ - path.join( - windowsDirectory, - "Microsoft.NET", - "Framework64", - "v4.0.30319", - "csc.exe" - ), - path.join( - windowsDirectory, - "Microsoft.NET", - "Framework", - "v4.0.30319", - "csc.exe" - ), - ]; - const compiler = candidates.find(existsSync); - if (!compiler) { - throw new Error( - `Windows .NET Framework C# compiler not found: ${candidates.join(", ")}` - ); - } - - const source = path.join(import.meta.dir, "windows-launcher.cs"); - const result = Bun.spawnSync( - [ - compiler, - "/nologo", - "/target:winexe", - "/platform:x64", - "/optimize+", - `/win32icon:${icon}`, - `/out:${output}`, - source, - ], - { stdio: ["ignore", "inherit", "inherit"] } - ); - if (result.exitCode !== 0 || !existsSync(output)) { - throw new Error( - `Windows console-free launcher compilation failed with ${result.exitCode}.` - ); - } -} diff --git a/apps/desktop/scripts/post-package.ts b/apps/desktop/scripts/post-package.ts index 8d6b37a1..5b8bcad4 100644 --- a/apps/desktop/scripts/post-package.ts +++ b/apps/desktop/scripts/post-package.ts @@ -4,6 +4,7 @@ import path from "node:path"; import rcedit from "rcedit"; +import { compileWindowsGuiExecutable } from "./windows-csharp"; import { patchWindowsExecutableFileToGui } from "./windows-executable"; if (process.env.ELECTROBUN_OS === "win") { @@ -32,9 +33,35 @@ if (process.env.ELECTROBUN_OS === "win") { ); } const setup = path.join(stagingDirectory, setupFiles[0]); - await rcedit(setup, { - icon: path.resolve(import.meta.dir, "..", "icon.ico"), + const setupCore = path.join(tempDirectory, "setup-core.exe"); + const installerPayload = path.join(stagingDirectory, ".installer"); + const metadataFiles = readdirSync(installerPayload).filter((name) => + name.endsWith(".metadata.json") + ); + const archiveFiles = readdirSync(installerPayload).filter((name) => + name.endsWith(".tar.zst") + ); + if (metadataFiles.length !== 1 || archiveFiles.length !== 1) { + throw new Error( + `Expected one installer metadata file and archive in ${installerZip}.` + ); + } + const metadata = path.join(installerPayload, metadataFiles[0]); + const archive = path.join(installerPayload, archiveFiles[0]); + const icon = path.resolve(import.meta.dir, "..", "icon.ico"); + copyFileSync(setup, setupCore); + compileWindowsGuiExecutable({ + source: path.join(import.meta.dir, "windows-installer.cs"), + output: setup, + icon, + resources: [ + { file: setupCore, name: "ElectrobunSetupCore" }, + { file: metadata, name: "ElectrobunSetupMetadata" }, + { file: archive, name: "ElectrobunSetupArchive" }, + ], }); + rmSync(archive, { force: true }); + await rcedit(setup, { icon }); patchWindowsExecutableFileToGui(setup); const repacked = path.join(tempDirectory, "installer.zip"); @@ -46,7 +73,9 @@ if (process.env.ELECTROBUN_OS === "win") { // Windows, so renameSync would fail with EXDEV here. copyFileSync replaces // the build artifact across volumes; a failure still fails the build hook. copyFileSync(repacked, zipPath); - console.info(`Embedded Windows icon: ${installerZip}/${setupFiles[0]}`); + console.info( + `Configured Windows installer progress UI: ${installerZip}/${setupFiles[0]}` + ); } finally { rmSync(tempDirectory, { force: true, recursive: true }); } diff --git a/apps/desktop/scripts/smoke-test-windows.ps1 b/apps/desktop/scripts/smoke-test-windows.ps1 index e1cde95e..1f01fc6a 100644 --- a/apps/desktop/scripts/smoke-test-windows.ps1 +++ b/apps/desktop/scripts/smoke-test-windows.ps1 @@ -8,6 +8,21 @@ param( $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest +Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class LlmSpaceNativeWindow +{ + [DllImport("user32.dll")] + public static extern IntPtr SendMessage( + IntPtr window, + uint message, + IntPtr wParam, + IntPtr lParam + ); +} +"@ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw "The Windows package smoke test must run on Windows." } @@ -171,10 +186,43 @@ try { if (-not $metadata.identifier -or -not $metadata.channel) { throw "Installer metadata must contain identifier and channel." } - + $installerPayload = Join-Path $expandedInstaller ".installer" + Remove-Item -LiteralPath $installerPayload -Recurse -Force + if (Test-Path -LiteralPath $installerPayload) { + throw "Setup.exe must remain installable without the external .installer payload." + } Assert-WindowsGuiExecutable $setupFiles[0].FullName "Setup.exe" Write-Host "Installing $($installerZip[0].Name) into isolated LOCALAPPDATA..." - $setupProcess = Start-Process -FilePath $setupFiles[0].FullName -Wait -PassThru + $setupProcess = Start-Process -FilePath $setupFiles[0].FullName -PassThru + [void]$ownedProcessIds.Add($setupProcess.Id) + $setupWindowSeen = $false + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while (-not $setupProcess.HasExited -and [DateTime]::UtcNow -lt $deadline) { + $setupProcess.Refresh() + if ($setupProcess.HasExited) { break } + $setupDescendants = @(Get-DescendantProcesses $setupProcess.Id) + $setupDescendants | ForEach-Object { [void]$ownedProcessIds.Add([int]$_.ProcessId) } + $visibleSetupProcesses = @(@($setupProcess) + @($setupDescendants | ForEach-Object { + Get-Process -Id ([int]$_.ProcessId) -ErrorAction SilentlyContinue + }) | Where-Object { $_.MainWindowHandle -ne 0 }) + $unexpectedSetupWindows = @($visibleSetupProcesses | Where-Object { + $_.MainWindowTitle -and $_.MainWindowTitle -ne "LLM Space Setup" + }) + if ($unexpectedSetupWindows.Count -gt 0) { + $titles = @($unexpectedSetupWindows | ForEach-Object { "$($_.ProcessName): $($_.MainWindowTitle)" }) -join "; " + throw "Expected only the LLM Space installation progress window; found: $titles" + } + if ($visibleSetupProcesses | Where-Object { $_.MainWindowTitle -eq "LLM Space Setup" }) { + $setupWindowSeen = $true + } + Start-Sleep -Milliseconds 100 + } + if (-not $setupProcess.HasExited) { + throw "Setup.exe did not finish within $TimeoutSeconds seconds." + } + if (-not $setupWindowSeen) { + throw "Setup.exe did not show the LLM Space installation progress window." + } if ($setupProcess.ExitCode -ne 0) { throw "Setup.exe exited with code $($setupProcess.ExitCode)." } @@ -239,7 +287,22 @@ try { if (-not $mainWindow) { throw "The installed app did not create the LLM Space main window." } - + $smallIcon = [LlmSpaceNativeWindow]::SendMessage( + $mainWindow.MainWindowHandle, + 0x007F, + [IntPtr]::Zero, + [IntPtr]::Zero + ) + $largeIcon = [LlmSpaceNativeWindow]::SendMessage( + $mainWindow.MainWindowHandle, + 0x007F, + [IntPtr]::new(1), + [IntPtr]::Zero + ) + if ($smallIcon -eq [IntPtr]::Zero -or $largeIcon -eq [IntPtr]::Zero) { + throw "The LLM Space window did not load its small and large application icons." + } + Write-Host "Verified LLM Space native window icons." Write-Host "Observing the single app window and process liveness for $ObservationSeconds seconds..." for ($second = 0; $second -lt $ObservationSeconds; $second++) { if (-not (Get-Process -Id $launcherProcess.Id -ErrorAction SilentlyContinue)) { diff --git a/apps/desktop/scripts/windows-csharp.ts b/apps/desktop/scripts/windows-csharp.ts new file mode 100644 index 00000000..b8005d74 --- /dev/null +++ b/apps/desktop/scripts/windows-csharp.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +export interface WindowsEmbeddedResource { + file: string; + name: string; +} + +export function compileWindowsGuiExecutable(options: { + source: string; + output: string; + icon: string; + resources?: WindowsEmbeddedResource[]; +}): void { + const compiler = _findCompiler(); + const resourceArguments = (options.resources ?? []).map( + (resource) => `/resource:${resource.file},${resource.name}` + ); + const result = Bun.spawnSync( + [ + compiler, + "/nologo", + "/target:winexe", + "/platform:x64", + "/optimize+", + "/reference:System.Drawing.dll", + "/reference:System.Windows.Forms.dll", + `/win32icon:${options.icon}`, + `/out:${options.output}`, + ...resourceArguments, + options.source, + ], + { stdio: ["ignore", "inherit", "inherit"] } + ); + if (result.exitCode !== 0 || !existsSync(options.output)) { + throw new Error( + `Windows GUI executable compilation failed with ${result.exitCode}: ${options.output}` + ); + } +} + +function _findCompiler(): string { + const windowsDirectory = process.env.WINDIR ?? "C:\\Windows"; + const candidates = [ + path.join( + windowsDirectory, + "Microsoft.NET", + "Framework64", + "v4.0.30319", + "csc.exe" + ), + path.join( + windowsDirectory, + "Microsoft.NET", + "Framework", + "v4.0.30319", + "csc.exe" + ), + ]; + const compiler = candidates.find(existsSync); + if (!compiler) { + throw new Error( + `Windows .NET Framework C# compiler not found: ${candidates.join(", ")}` + ); + } + return compiler; +} diff --git a/apps/desktop/scripts/windows-executable.test.ts b/apps/desktop/scripts/windows-executable.test.ts deleted file mode 100644 index 0613ac6e..00000000 --- a/apps/desktop/scripts/windows-executable.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - getWindowsExecutableSubsystem, - patchWindowsExecutableToConsole, - patchWindowsExecutableToGui, -} from "./windows-executable"; - -const PE_OFFSET = 0x80; -const OPTIONAL_HEADER_SIZE = 0xf0; -const SUBSYSTEM_OFFSET = PE_OFFSET + 4 + 20 + 68; - -function _makePeImage(subsystem: number): Uint8Array { - const image = new Uint8Array(PE_OFFSET + 4 + 20 + OPTIONAL_HEADER_SIZE); - image[0] = 0x4d; - image[1] = 0x5a; - new DataView(image.buffer).setUint32(0x3c, PE_OFFSET, true); - image.set([0x50, 0x45, 0, 0], PE_OFFSET); - - const coffOffset = PE_OFFSET + 4; - new DataView(image.buffer).setUint16(coffOffset, 0x8664, true); - new DataView(image.buffer).setUint16( - coffOffset + 16, - OPTIONAL_HEADER_SIZE, - true - ); - - const optionalOffset = coffOffset + 20; - new DataView(image.buffer).setUint16(optionalOffset, 0x20b, true); - new DataView(image.buffer).setUint16(SUBSYSTEM_OFFSET, subsystem, true); - return image; -} - -describe("Windows PE subsystem helper", () => { - test("reads and converts a console PE image to GUI", () => { - const image = _makePeImage(3); - - expect(getWindowsExecutableSubsystem(image)).toBe(3); - const patched = patchWindowsExecutableToGui(image); - - expect(getWindowsExecutableSubsystem(patched)).toBe(2); - expect(getWindowsExecutableSubsystem(image)).toBe(3); - }); - - test("restores a GUI PE image to the console subsystem", () => { - const image = _makePeImage(2); - - const patched = patchWindowsExecutableToConsole(image); - - expect(getWindowsExecutableSubsystem(patched)).toBe(3); - expect(getWindowsExecutableSubsystem(image)).toBe(2); - }); - - test("is idempotent for an existing GUI image", () => { - const image = _makePeImage(2); - - expect( - getWindowsExecutableSubsystem(patchWindowsExecutableToGui(image)) - ).toBe(2); - }); - - test("rejects an unsupported subsystem", () => { - expect(() => patchWindowsExecutableToGui(_makePeImage(9))).toThrow( - "Unsupported Windows PE subsystem 9" - ); - }); - - test("rejects a non-PE file", () => { - expect(() => getWindowsExecutableSubsystem(new Uint8Array(128))).toThrow( - "missing DOS header" - ); - }); -}); diff --git a/apps/desktop/scripts/windows-installer.cs b/apps/desktop/scripts/windows-installer.cs new file mode 100644 index 00000000..2a21fa68 --- /dev/null +++ b/apps/desktop/scripts/windows-installer.cs @@ -0,0 +1,281 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Forms; + +internal static class WindowsInstaller +{ + private const string AppName = "LLM Space"; + private const string CoreResourceName = "ElectrobunSetupCore"; + private const string MetadataResourceName = "ElectrobunSetupMetadata"; + private const string ArchiveResourceName = "ElectrobunSetupArchive"; + + [STAThread] + private static int Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + using (InstallerForm form = new InstallerForm()) + { + Application.Run(form); + return form.ExitCode; + } + } + + private sealed class InstallerForm : Form + { + private readonly Label statusLabel; + private readonly ProgressBar progressBar; + private Process coreProcess; + private string coreDirectory; + private volatile bool cancelRequested; + private bool finished; + + internal InstallerForm() + { + ExitCode = 1; + Text = AppName + " Setup"; + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ClientSize = new Size(420, 132); + AutoScaleMode = AutoScaleMode.Dpi; + BackColor = SystemColors.Window; + Padding = new Padding(28, 24, 28, 22); + + Icon executableIcon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); + if (executableIcon != null) + { + Icon = executableIcon; + } + + statusLabel = new Label + { + AutoSize = false, + Dock = DockStyle.Top, + Height = 42, + Text = "Installing " + AppName + "…", + Font = new Font(SystemFonts.MessageBoxFont.FontFamily, 12F, FontStyle.Regular), + ForeColor = SystemColors.WindowText, + TextAlign = ContentAlignment.MiddleLeft, + }; + progressBar = new ProgressBar + { + Dock = DockStyle.Top, + Height = 18, + Style = ProgressBarStyle.Marquee, + MarqueeAnimationSpeed = 28, + }; + + Controls.Add(progressBar); + Controls.Add(statusLabel); + Shown += OnShown; + FormClosing += OnFormClosing; + } + + internal int ExitCode { get; private set; } + + private async void OnShown(object sender, EventArgs args) + { + int exitCode; + try + { + exitCode = await Task.Run(() => RunCoreInstaller()); + } + catch (Exception error) + { + if (cancelRequested) + { + FinishAndClose(2); + return; + } + + MessageBox.Show( + this, + "Installation failed.\n\n" + error.Message, + AppName + " Setup", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + FinishAndClose(1); + return; + } + + if (cancelRequested) + { + FinishAndClose(2); + return; + } + + if (exitCode != 0) + { + MessageBox.Show( + this, + "Installation failed with exit code " + exitCode + ".", + AppName + " Setup", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + FinishAndClose(exitCode); + return; + } + + progressBar.Style = ProgressBarStyle.Continuous; + progressBar.MarqueeAnimationSpeed = 0; + progressBar.Value = 100; + statusLabel.Text = "Installation complete"; + await Task.Delay(700); + FinishAndClose(0); + } + + private int RunCoreInstaller() + { + string installerDirectory = AppDomain.CurrentDomain.BaseDirectory; + string setupFileName = Path.GetFileName(Application.ExecutablePath); + string setupStem = Path.GetFileNameWithoutExtension(setupFileName); + coreDirectory = Path.Combine( + installerDirectory, + ".llm-space-setup-" + Guid.NewGuid().ToString("N") + ); + string coreInstallerDirectory = Path.Combine(coreDirectory, ".installer"); + string corePath = Path.Combine(coreDirectory, setupFileName); + + try + { + Directory.CreateDirectory(coreInstallerDirectory); + File.SetAttributes( + coreDirectory, + File.GetAttributes(coreDirectory) | FileAttributes.Hidden + ); + ExtractResource(CoreResourceName, corePath); + ExtractResource( + MetadataResourceName, + Path.Combine(coreInstallerDirectory, setupStem + ".metadata.json") + ); + ExtractResource( + ArchiveResourceName, + Path.Combine(coreInstallerDirectory, setupStem + ".tar.zst") + ); + + if (cancelRequested) + { + return 2; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = corePath, + WorkingDirectory = coreDirectory, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + + using (Process process = new Process { StartInfo = startInfo }) + { + coreProcess = process; + if (!process.Start()) + { + return 3; + } + if (cancelRequested && !process.HasExited) + { + process.Kill(); + } + process.WaitForExit(); + return process.ExitCode; + } + } + finally + { + coreProcess = null; + TryDeleteCoreDirectory(); + } + } + + private static void ExtractResource(string resourceName, string destination) + { + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream input = assembly.GetManifestResourceStream(resourceName)) + { + if (input == null) + { + throw new InvalidOperationException( + "Embedded installer payload is missing: " + resourceName + ); + } + using (FileStream output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + input.CopyTo(output); + } + } + } + + private void OnFormClosing(object sender, FormClosingEventArgs args) + { + if (finished) + { + return; + } + + args.Cancel = true; + if (cancelRequested) + { + return; + } + + DialogResult result = MessageBox.Show( + this, + "Cancel the installation?", + AppName + " Setup", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question + ); + if (result != DialogResult.Yes) + { + return; + } + + cancelRequested = true; + statusLabel.Text = "Cancelling installation…"; + progressBar.MarqueeAnimationSpeed = 0; + Process process = coreProcess; + if (process != null && !process.HasExited) + { + try + { + process.Kill(); + } + catch (InvalidOperationException) + { + } + } + } + + private void FinishAndClose(int exitCode) + { + ExitCode = exitCode; + finished = true; + Close(); + } + + private void TryDeleteCoreDirectory() + { + if (String.IsNullOrEmpty(coreDirectory)) + { + return; + } + try + { + Directory.Delete(coreDirectory, true); + } + catch + { + } + } + } +} diff --git a/patches/electrobun@1.18.1.patch b/patches/electrobun@1.18.1.patch index 5b6e5f6e..ce1fd933 100644 --- a/patches/electrobun@1.18.1.patch +++ b/patches/electrobun@1.18.1.patch @@ -1,6 +1,64 @@ diff --git a/dist/api/bun/core/Socket.ts b/dist/api/bun/core/Socket.ts -index cdaa2bca0fabe577cb667056aa6f8f84211189c2..2d291f7dd7b97a644d40f2d105b0e7028387b0ce 100644 --- a/dist/api/bun/core/Socket.ts +++ b/dist/api/bun/core/Socket.ts @@ -67,0 +68 @@ const startRPCServer = () => { -+ hostname: "127.0.0.1", ++ ...(process.platform === "win32" ? { hostname: "127.0.0.1" } : {}), +diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts +--- a/dist/api/bun/proc/native.ts ++++ b/dist/api/bun/proc/native.ts +@@ -81,0 +82,52 @@ function getWindowPtr(winId: number) { ++const windowsWindowIconApi = ++ process.platform === "win32" ++ ? dlopen("user32.dll", { ++ LoadImageW: { ++ args: [ ++ FFIType.ptr, ++ FFIType.ptr, ++ FFIType.u32, ++ FFIType.i32, ++ FFIType.i32, ++ FFIType.u32, ++ ], ++ returns: FFIType.ptr, ++ }, ++ SendMessageW: { ++ args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr], ++ returns: FFIType.ptr, ++ }, ++ }) ++ : null; ++ ++function setWindowsWindowIcon(windowPtr: Pointer): void { ++ if (!windowsWindowIconApi) return; ++ ++ const iconPath = Buffer.from( ++ `${join(process.cwd(), "..", "Resources", "app.ico")}\0`, ++ "utf16le", ++ ); ++ const smallIcon = windowsWindowIconApi.symbols.LoadImageW( ++ null, ++ ptr(iconPath), ++ 1, ++ 16, ++ 16, ++ 0x10, ++ ); ++ const largeIcon = windowsWindowIconApi.symbols.LoadImageW( ++ null, ++ ptr(iconPath), ++ 1, ++ 32, ++ 32, ++ 0x10, ++ ); ++ if (smallIcon) { ++ windowsWindowIconApi.symbols.SendMessageW(windowPtr, 0x80, 0, smallIcon); ++ } ++ if (largeIcon) { ++ windowsWindowIconApi.symbols.SendMessageW(windowPtr, 0x80, 1, largeIcon); ++ } ++} ++ +@@ -927,0 +980,2 @@ const _ffiImpl = { ++ setWindowsWindowIcon(windowPtr); ++ From 288c83f1bd970c9a9b89464d229678689538bc8d Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:16:03 +0800 Subject: [PATCH 11/17] fix(windows): keep native integration app-owned --- AGENTS.md | 14 +--- apps/desktop/src/bun/app/window.ts | 5 ++ .../src/bun/app/windows-window-icon.ts | 63 ++++++++++++++++++ bun.lock | 3 - package.json | 3 - patches/electrobun@1.18.1.patch | 64 ------------------- 6 files changed, 70 insertions(+), 82 deletions(-) create mode 100644 apps/desktop/src/bun/app/windows-window-icon.ts delete mode 100644 patches/electrobun@1.18.1.patch diff --git a/AGENTS.md b/AGENTS.md index 4d8af99a..b3a6fb45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,6 @@ A workbench for prompt and agent development — build, trace, debug, evaluate, | Build (stable) | `mise run build:stable` | → `vite build && electrobun build --env=stable` in `apps/desktop` | | Build the web site | `mise run build:web` | → `bun --filter @llm-space/web build` (static, `base=/llm-space/`, out to `apps/web/dist`). CI + the Pages workflow run this | | Local packaging / update test | `mise run pack` · `pack:perf` · `pack:adhoc` · `pack:signed` · `pack:feed` + `feed:serve` | env combinations over `build:canary` (skip signing / CEF Performance edition / ad-hoc sign / local update feed on :8321); defined in `mise.toml` | -| Windows x64 package gates | `mise run pack:win` · `verify:pack:win` · `smoke:win` | unsigned regular Canary build; archive/update verification; isolated install + launch smoke test (Windows host required) | | Cut a release | `mise run release` / `mise run release:canary` | → `bun scripts/release.ts`; see "Releases & auto-update" | | Test | `mise run test` | runs the complete Bun test suite from the repository root | | Lint | `mise run lint` / `mise run lint:fix` | `lint` = `eslint .` (read-only), `lint:fix` = `eslint --fix .`; flat config at repo root | @@ -129,20 +128,13 @@ State is **persisted to disk** under the llm-space root (`~/.llm-space` by defau The app version has a **single source of truth: `apps/desktop/package.json`** — `electrobun.config.ts` imports it, and release CI fails if the pushed tag doesn't match. Cut releases with `mise run release` (stable) or `mise run release:canary`; the script (`scripts/release.ts`) runs `commit-and-tag-version` (conventional-commits-driven version bump + commit + `v*` tag, config in `.versionrc.json`) and pushes atomically. Automated changelog generation is off (`skip.changelog`) — the `CHANGELOG.md` at the repo root is **hand-curated** (Keep a Changelog format), so before cutting a stable release, add a `## [x.y.z]` section for it. CI builds each versioned release's GitHub notes by extracting that version's `CHANGELOG.md` section (no `--generate-notes`, which would dump commits/authors) and appending the install blurb; prereleases with no changelog entry (canary) fall back to install-only notes. The tag triggers `.github/workflows/release.yml`: build → codesign/notarize (canary/stable only; needs the `MACOS_*`/`ASC_*` signing secrets, mapped to electrobun's `ELECTROBUN_*` env vars in the workflow) → smoke test → upload. Artifacts land in two GitHub releases: the rolling `updates` release is the machine-readable update feed (`release.baseUrl` points at it; **never delete its `.patch` files** — old installs chain through them), and a versioned release carries the DMG for humans. In-app auto-update lives in `bun/updates/` (background check → silent download → "restart to update" toast via the `updateStatusChanged` message); the dev channel never updates. -**Two macOS editions ship from every tag.** The regular one drives the system WebView; the **Performance** edition embeds Chromium (CEF). `LLM_SPACE_DESKTOP_RENDERER=cef` is the only switch — `electrobun.config.ts` forks the app name (`LLM Space Performance`), the identifier (`…llm-space.performance`) and the update feed off it, so the two install side by side in `/Applications` and update independently. They deliberately **share `~/.llm-space`** (`getLlmSpaceHomePath()` is name-independent), so switching editions keeps threads and settings. Two things make this work and will silently break if touched: (1) each edition needs its **own rolling update release** (`updates` / `updates-performance`) — `update.json` is named `{channel}-{os}-{arch}-update.json` with no app name, so a shared release would have them overwrite each other; hence the release workflow downloads the two editions' artifacts into **separate directories** rather than `merge-multiple` into one. (2) CEF must **not** get a `remote-debugging-port` in shipped builds — `chromiumFlags` is only set when `LLM_SPACE_DESKTOP_CDP_PORT` is explicitly passed (which `dev:cef` does, and CI never does); an always-on CDP port would let any local process drive the renderer. Build the Performance edition locally with `mise run pack:perf`. +**Two editions ship from every tag.** The regular one drives the system WebView; the **Performance** edition embeds Chromium (CEF). `LLM_SPACE_DESKTOP_RENDERER=cef` is the only switch — `electrobun.config.ts` forks the app name (`LLM Space Performance`), the identifier (`…llm-space.performance`) and the update feed off it, so the two install side by side in `/Applications` and update independently. They deliberately **share `~/.llm-space`** (`getLlmSpaceHomePath()` is name-independent), so switching editions keeps threads and settings. Two things make this work and will silently break if touched: (1) each edition needs its **own rolling update release** (`updates` / `updates-performance`) — `update.json` is named `{channel}-{os}-{arch}-update.json` with no app name, so a shared release would have them overwrite each other; hence the release workflow downloads the two editions' artifacts into **separate directories** rather than `merge-multiple` into one. (2) CEF must **not** get a `remote-debugging-port` in shipped builds — `chromiumFlags` is only set when `LLM_SPACE_DESKTOP_CDP_PORT` is explicitly passed (which `dev:cef` does, and CI never does); an always-on CDP port would let any local process drive the renderer. Build the Performance edition locally with `mise run pack:perf`. -Windows publication is deliberately narrower: tags are built and smoke-tested on **Windows x64 regular/WebView2**, but only the unsigned **Canary** artifact is published. It joins the regular `updates` rolling feed, while the human installer ZIP is renamed `LLMSpace-vX.Y.Z-windows-x64.zip` and uploaded only to the versioned Canary prerelease. Stable Windows publication, Authenticode, Windows ARM64, and Windows Performance/CEF remain out of scope. `apps/desktop/scripts/verify-windows-package.ts` rejects GNU `LongLink` and unsafe archive paths and checks the required payload/update metadata; `smoke-test-windows.ps1` installs under an isolated temporary `LOCALAPPDATA`, verifies the setup/launcher/Bun PE subsystems plus Desktop/Start Menu shortcut targets, launches the real installed app without `-WindowStyle Hidden`, asserts that only the LLM Space window is visible, then restores pre-existing shortcuts and removes only its owned processes and temporary root. - -Windows icon and console-free launch handling use project hooks because Electrobun 1.18.1's compiled CLI resolves its built-in `rcedit` path incorrectly and ships console-subsystem executables. `scripts/post-build.ts` renames Electrobun's original launcher to `launcher-core.exe`, compiles the small .NET Framework GUI-subsystem wrapper in `windows-launcher.cs` as `launcher.exe`, starts the core with `CreateNoWindow`, converts the private `bun.exe` to the GUI subsystem, and embeds `icon.ico` into all three binaries. `scripts/post-package.ts` compiles `windows-installer.cs` as the visible native progress window, embeds Electrobun's original extractor plus its metadata/archive payload, and runs that extractor with `CreateNoWindow`; Setup remains self-contained and the ZIP still exposes only one executable. The shared compiler helper is `windows-csharp.ts`. The Windows build host therefore needs the standard .NET Framework 4 C# compiler under `%WINDIR%\Microsoft.NET`; GitHub's Windows runner and Windows 10/11 include it. Keep `rcedit` as a direct desktop dev dependency until the upstream path is fixed. The Windows installer remains unsigned and may trigger SmartScreen. - -`patches/electrobun@1.18.1.patch` changes behavior only when `process.platform === "win32"`: it binds Electrobun's internal renderer RPC server to `127.0.0.1` to avoid the Windows Firewall alert, and uses `user32.dll` to put the bundled static `app.ico` on the native window because Electrobun's Windows `setWindowIcon` implementation is a no-op. The macOS socket behavior and window creation path remain unchanged. Keep these platform guards intact, and recheck whether the patch is still needed whenever Electrobun is upgraded. - -macOS releases ship for **arm64 + x64** (so four macOS build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (invoked by `scripts/post-build.ts` at `postBuild` and directly again at `postWrap`): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. +Releases ship for **macOS arm64 + x64** (so four build jobs per tag: 2 arches × 2 editions). Intel builds need `apps/desktop/scripts/fix-x64-headerpad.ts` (wired as the `postBuild`/`postWrap` hooks): electrobun's darwin-x64 core binaries have no Mach-O headerpad, so signing them corrupts `__text` and the app segfaults on launch (electrobun#485). The hook frees 16 bytes of load-command room before signing and no-ops everywhere else; delete it when upstream fixes #485. It deliberately scans only the top level of `Contents/MacOS/`, which does **not** cover the binaries CEF adds (the `Chromium Embedded Framework.framework` and the five `Contents/Frameworks/bun Helper*.app` helpers, whose executable is electrobun's `process_helper`). That is correct, and was verified against the real darwin-x64 core rather than assumed: the CEF framework ships pre-signed (adhoc, linker-signed), and x64 `process_helper` — although unsigned like `extractor`/`launcher` — already has headerpad ≥ 16, so `codesign` can append `LC_CODE_SIGNATURE` without corrupting it. Running the hook against the x64 core reports `process_helper — ok` while `extractor`/`launcher` come back `fixed`. Don't widen the hook's scan on a hunch; re-run that check instead. ### The command layer Every cross-boundary user action (menus, context menus, toolbar buttons, shortcuts) is a `Command` — a `type` discriminant + typed `args` — defined in `src/shared/commands.ts`. `COMMAND_META` tags each with a `target` of `"webview"` or `"bun"`. A single `executeCommand` on each side routes it: the bun side (`bun/commands.ts` `executeCommandInBun`) runs `bun`-target commands locally (window zoom/reload, open external links) and forwards `webview`-target ones over RPC; the renderer (`commands/index.tsx` `CommandProvider`) does the reverse. The native menu (`bun/app/menu.ts`) maps its string actions into commands. -On macOS, shared-thread custom URLs continue through `llm-space://shared/...`. Because Electrobun does not register that scheme on Windows, **Import from Clipboard** also recognizes the canonical `https://deer-flow.github.io/llm-space/#/shared/...` viewer URL and routes it through the same shared importer; non-URL clipboard JSON keeps the existing file-import behavior. ### App layout (`apps/desktop/src`) @@ -151,8 +143,6 @@ On macOS, shared-thread custom URLs continue through `llm-space://shared/...`. B - `bun/` — main-process code: `app/` (window, menu, window-state), `rpc/`, `streaming/`, `storage/`, `models/` (`ModelManager` + builtin/custom providers), `auth/` (`GitHubAuthManager` — OAuth Device Flow + `settings/auth.json`), `fs/` (trash/reveal), `reminders/` (one-time feature reminders + GitHub-star reminder, persisted to `settings/reminders.json`; reminder definitions ship in code at `shared/feature-reminders.ts` — append to `FEATURE_REMINDERS`, never reorder or reuse an `id`), `env/hydrate` (loads login-shell env — API keys/PATH — before anything reads `process.env`), `workspace/seed`. > **GitHub calls go through the proxy.** GitHub auth (`bun/auth/`) and any future gist calls run from the **bun process** using the global `fetch`, which `NetworkSettingsManager` (`bun/network/`) routes through the user's configured proxy by writing `HTTP(S)_PROXY` onto `process.env`. Just call `fetch` — never add a bypassing custom dispatcher, or corporate/proxied users' GitHub requests will fail. -> -> **Windows capability boundaries.** Resolve Bash once at composition time with `Bun.which("bash")`; omit the Bash tool when it is unavailable instead of silently substituting PowerShell. MCP stdio remains argument-array based through the SDK's `cross-spawn` path, which handles Windows `.cmd`/`.bat`/`.exe` resolution—do not add a POSIX shell dependency. The proxy "system" path on Windows currently means `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`; Windows Internet Settings/WinHTTP are not auto-detected, and the settings UI must say so. - `client/` — renderer-side RPC callers: `rpc-transport.ts` (streaming) and `local-file-system.ts` (the `fs*` requests). - `host/` — `host-services.tsx`: the desktop `HostServices` + `ModelClient` impls (`DesktopHostProvider`, `createElectrobunModelClient`) feeding the shared `@llm-space/ui` playground. - `shared/` — code used by both contexts: `rpc.ts`, `commands.ts`. diff --git a/apps/desktop/src/bun/app/window.ts b/apps/desktop/src/bun/app/window.ts index 94ddea65..b2d227d3 100644 --- a/apps/desktop/src/bun/app/window.ts +++ b/apps/desktop/src/bun/app/window.ts @@ -14,6 +14,7 @@ import type { MainWindowRPC } from "../rpc"; import { registerMenuActions } from "./menu"; import { getWindowChromeOptions } from "./window-options"; import { attachWindowStates } from "./window-state"; +import { setWindowsWindowIcon } from "./windows-window-icon"; const DEV_SERVER_PORT = 5173; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; @@ -55,6 +56,10 @@ export async function createMainWindow({ frame: savedFrame, }); + if (process.platform === "win32") { + setWindowsWindowIcon(window.ptr); + } + attachWindowStates(window, { isMaximized: getWindowMaximized(windowState), isFullScreen: getWindowFullScreen(windowState), diff --git a/apps/desktop/src/bun/app/windows-window-icon.ts b/apps/desktop/src/bun/app/windows-window-icon.ts new file mode 100644 index 00000000..6456cbb1 --- /dev/null +++ b/apps/desktop/src/bun/app/windows-window-icon.ts @@ -0,0 +1,63 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi"; + +const IMAGE_ICON = 1; +const LR_LOADFROMFILE = 0x10; +const WM_SETICON = 0x80; +const ICON_SMALL = 0; +const ICON_BIG = 1; + +export function setWindowsWindowIcon(windowPtr: Pointer): void { + if (process.platform !== "win32") return; + + const iconPath = path.join(process.cwd(), "..", "Resources", "app.ico"); + if (!existsSync(iconPath)) return; + + try { + const user32 = dlopen("user32.dll", { + LoadImageW: { + args: [ + FFIType.ptr, + FFIType.ptr, + FFIType.u32, + FFIType.i32, + FFIType.i32, + FFIType.u32, + ], + returns: FFIType.ptr, + }, + SendMessageW: { + args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr], + returns: FFIType.ptr, + }, + }); + const wideIconPath = Buffer.from(`${iconPath}\0`, "utf16le"); + const smallIcon = user32.symbols.LoadImageW( + null, + ptr(wideIconPath), + IMAGE_ICON, + 16, + 16, + LR_LOADFROMFILE + ); + const largeIcon = user32.symbols.LoadImageW( + null, + ptr(wideIconPath), + IMAGE_ICON, + 32, + 32, + LR_LOADFROMFILE + ); + + if (smallIcon) { + user32.symbols.SendMessageW(windowPtr, WM_SETICON, ICON_SMALL, smallIcon); + } + if (largeIcon) { + user32.symbols.SendMessageW(windowPtr, WM_SETICON, ICON_BIG, largeIcon); + } + } catch (error) { + console.warn("Unable to set the Windows window icon:", error); + } +} diff --git a/bun.lock b/bun.lock index db81693b..ae53e558 100644 --- a/bun.lock +++ b/bun.lock @@ -186,9 +186,6 @@ }, }, }, - "patchedDependencies": { - "electrobun@1.18.1": "patches/electrobun@1.18.1.patch", - }, "overrides": { "@codemirror/state": "6.7.0", "@codemirror/view": "6.43.5", diff --git a/package.json b/package.json index 73c043b5..f97b8ca4 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,5 @@ "react": "catalog:", "react-dom": "catalog:", "typebox": "catalog:" - }, - "patchedDependencies": { - "electrobun@1.18.1": "patches/electrobun@1.18.1.patch" } } diff --git a/patches/electrobun@1.18.1.patch b/patches/electrobun@1.18.1.patch deleted file mode 100644 index ce1fd933..00000000 --- a/patches/electrobun@1.18.1.patch +++ /dev/null @@ -1,64 +0,0 @@ -diff --git a/dist/api/bun/core/Socket.ts b/dist/api/bun/core/Socket.ts ---- a/dist/api/bun/core/Socket.ts -+++ b/dist/api/bun/core/Socket.ts -@@ -67,0 +68 @@ const startRPCServer = () => { -+ ...(process.platform === "win32" ? { hostname: "127.0.0.1" } : {}), -diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts ---- a/dist/api/bun/proc/native.ts -+++ b/dist/api/bun/proc/native.ts -@@ -81,0 +82,52 @@ function getWindowPtr(winId: number) { -+const windowsWindowIconApi = -+ process.platform === "win32" -+ ? dlopen("user32.dll", { -+ LoadImageW: { -+ args: [ -+ FFIType.ptr, -+ FFIType.ptr, -+ FFIType.u32, -+ FFIType.i32, -+ FFIType.i32, -+ FFIType.u32, -+ ], -+ returns: FFIType.ptr, -+ }, -+ SendMessageW: { -+ args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr], -+ returns: FFIType.ptr, -+ }, -+ }) -+ : null; -+ -+function setWindowsWindowIcon(windowPtr: Pointer): void { -+ if (!windowsWindowIconApi) return; -+ -+ const iconPath = Buffer.from( -+ `${join(process.cwd(), "..", "Resources", "app.ico")}\0`, -+ "utf16le", -+ ); -+ const smallIcon = windowsWindowIconApi.symbols.LoadImageW( -+ null, -+ ptr(iconPath), -+ 1, -+ 16, -+ 16, -+ 0x10, -+ ); -+ const largeIcon = windowsWindowIconApi.symbols.LoadImageW( -+ null, -+ ptr(iconPath), -+ 1, -+ 32, -+ 32, -+ 0x10, -+ ); -+ if (smallIcon) { -+ windowsWindowIconApi.symbols.SendMessageW(windowPtr, 0x80, 0, smallIcon); -+ } -+ if (largeIcon) { -+ windowsWindowIconApi.symbols.SendMessageW(windowPtr, 0x80, 1, largeIcon); -+ } -+} -+ -@@ -927,0 +980,2 @@ const _ffiImpl = { -+ setWindowsWindowIcon(windowPtr); -+ From 848d7791b0efbfa6db3131801234541387b37b5c Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:22:34 +0800 Subject: [PATCH 12/17] chore(windows): remove branch-only test files --- CHANGELOG.md | 27 ---- .../scripts/verify-windows-package.test.ts | 119 ------------------ apps/desktop/scripts/vite-config.test.ts | 15 --- .../src/bun/app/window-options.test.ts | 21 ---- apps/desktop/src/bun/deep-link/index.test.ts | 24 ---- .../desktop/src/bun/tools/built-in/fs.test.ts | 46 ------- 6 files changed, 252 deletions(-) delete mode 100644 apps/desktop/scripts/verify-windows-package.test.ts delete mode 100644 apps/desktop/scripts/vite-config.test.ts delete mode 100644 apps/desktop/src/bun/app/window-options.test.ts delete mode 100644 apps/desktop/src/bun/deep-link/index.test.ts delete mode 100644 apps/desktop/src/bun/tools/built-in/fs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d371ce7..e50707bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,30 +4,6 @@ All notable changes to LLM Space are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added - -- Windows 10/11 x64 Canary packages using the system WebView2 runtime, with - automated archive verification and isolated install/start smoke tests. -- HTTPS shared-thread viewer URLs can be imported through **File → Import from - Clipboard**, providing a Windows-safe alternative to the unsupported custom - URL scheme. - -### Changed - -- Windows installation and shortcut launches no longer open command windows; - only the installer progress and LLM Space application window remain visible. -- On Windows, Electrobun's internal renderer RPC server is restricted to - loopback, avoiding a Windows Firewall prompt on first launch. -- Windows uses the native title bar so resize, snap, minimize, maximize, and - close behavior follow the operating system, and the title bar now displays - the bundled LLM Space icon. -- The Bash tool is offered only when Bash is available; structured file tools - continue to work without it. -- Windows proxy settings now accurately describe the supported environment - variables and the lack of automatic Internet Settings/WinHTTP detection. - ## [4.3.0] - 2026-07-20 Prompt templates grow up: Jinja2 logic, richer variables, and in-app reminders @@ -129,9 +105,6 @@ Baseline for this changelog. See the [GitHub releases](https://github.com/deer-flow/llm-space/releases) for earlier history. -[Unreleased]: https://github.com/deer-flow/llm-space/compare/v4.3.0...HEAD -[4.3.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.3.0 -[4.2.1]: https://github.com/deer-flow/llm-space/releases/tag/v4.2.1 [4.2.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.2.0 [4.1.1]: https://github.com/deer-flow/llm-space/releases/tag/v4.1.1 [4.1.0]: https://github.com/deer-flow/llm-space/releases/tag/v4.1.0 diff --git a/apps/desktop/scripts/verify-windows-package.test.ts b/apps/desktop/scripts/verify-windows-package.test.ts deleted file mode 100644 index f15dc8aa..00000000 --- a/apps/desktop/scripts/verify-windows-package.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - verifyWindowsTar, - verifyWindowsUpdateJson, -} from "./verify-windows-package"; - -interface FixtureEntry { - name: string; - type?: string; - content?: string; -} - -const REQUIRED_ENTRIES: FixtureEntry[] = [ - { name: "LLMSpace-canary/bin/launcher.exe" }, - { name: "LLMSpace-canary/bin/launcher-core.exe" }, - { name: "LLMSpace-canary/bin/bun.exe" }, - { name: "LLMSpace-canary/Resources/main.js" }, - { name: "LLMSpace-canary/Resources/app/views/mainview/index.html" }, -]; - -function _tar(entries: FixtureEntry[]): Uint8Array { - const chunks: Uint8Array[] = []; - for (const entry of entries) { - const content = new TextEncoder().encode(entry.content ?? "fixture"); - const header = new Uint8Array(512); - _writeString(header, 0, 100, entry.name); - _writeString(header, 100, 8, "0000644\0"); - _writeString(header, 108, 8, "0000000\0"); - _writeString(header, 116, 8, "0000000\0"); - _writeString( - header, - 124, - 12, - `${content.length.toString(8).padStart(11, "0")}\0` - ); - _writeString(header, 136, 12, "00000000000\0"); - header.fill(0x20, 148, 156); - header[156] = (entry.type ?? "0").charCodeAt(0); - _writeString(header, 257, 6, "ustar\0"); - _writeString(header, 263, 2, "00"); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - _writeString(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `); - chunks.push(header, content); - const padding = (512 - (content.length % 512)) % 512; - if (padding) chunks.push(new Uint8Array(padding)); - } - chunks.push(new Uint8Array(1024)); - const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const tar = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - tar.set(chunk, offset); - offset += chunk.length; - } - return tar; -} - -function _writeString( - target: Uint8Array, - offset: number, - length: number, - value: string -): void { - const encoded = new TextEncoder().encode(value); - if (encoded.length > length) throw new Error("fixture field is too long"); - target.set(encoded, offset); -} - -describe("verifyWindowsTar", () => { - test("accepts a portable archive with the required Windows payload", () => { - expect(verifyWindowsTar(_tar(REQUIRED_ENTRIES))).toHaveLength(5); - }); - - test.each(["../outside.txt", "/absolute.txt", "C:/absolute.txt"])( - "rejects unsafe archive path %s", - (name) => { - expect(() => - verifyWindowsTar(_tar([...REQUIRED_ENTRIES, { name }])) - ).toThrow("unsafe path"); - } - ); - - test("rejects GNU LongLink records with an actionable error", () => { - expect(() => - verifyWindowsTar( - _tar([ - ...REQUIRED_ENTRIES, - { name: "././@LongLink", type: "L", content: "very-long-name" }, - ]) - ) - ).toThrow("GNU LongLink"); - }); - - test("rejects an archive missing a required runtime entry", () => { - expect(() => verifyWindowsTar(_tar(REQUIRED_ENTRIES.slice(1)))).toThrow( - "bin/launcher.exe" - ); - }); -}); - -describe("verifyWindowsUpdateJson", () => { - test("accepts the Windows x64 update identity", () => { - expect( - verifyWindowsUpdateJson( - JSON.stringify({ version: "4.3.0", platform: "win", arch: "x64" }) - ) - ).toMatchObject({ platform: "win", arch: "x64" }); - }); - - test.each([ - { platform: "macos", arch: "x64" }, - { platform: "win", arch: "arm64" }, - ])("rejects update identity $platform/$arch", (identity) => { - expect(() => verifyWindowsUpdateJson(JSON.stringify(identity))).toThrow( - "Windows x64" - ); - }); -}); diff --git a/apps/desktop/scripts/vite-config.test.ts b/apps/desktop/scripts/vite-config.test.ts deleted file mode 100644 index 87cac5db..00000000 --- a/apps/desktop/scripts/vite-config.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import viteConfig from "../vite.config"; - -describe("desktop Vite packaging", () => { - test("uses short imported-asset names for the Windows tar extractor", () => { - const output = viteConfig.build?.rollupOptions?.output; - - expect(output).toBeDefined(); - expect(Array.isArray(output)).toBe(false); - if (!output || Array.isArray(output)) return; - - expect(output.assetFileNames).toBe("assets/[hash][extname]"); - }); -}); diff --git a/apps/desktop/src/bun/app/window-options.test.ts b/apps/desktop/src/bun/app/window-options.test.ts deleted file mode 100644 index e4796b3a..00000000 --- a/apps/desktop/src/bun/app/window-options.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { getWindowChromeOptions } from "./window-options"; - -describe("getWindowChromeOptions", () => { - test("keeps the inset title bar and traffic lights on macOS", () => { - expect(getWindowChromeOptions("darwin")).toEqual({ - titleBarStyle: "hiddenInset", - trafficLightOffset: { x: 2, y: 16 }, - }); - }); - - test.each(["win32", "linux"] as const)( - "uses native title-bar behavior on %s", - (platform) => { - expect(getWindowChromeOptions(platform)).toEqual({ - titleBarStyle: "default", - }); - } - ); -}); diff --git a/apps/desktop/src/bun/deep-link/index.test.ts b/apps/desktop/src/bun/deep-link/index.test.ts deleted file mode 100644 index b1c704a9..00000000 --- a/apps/desktop/src/bun/deep-link/index.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseSharedImportUrl } from "./index"; - -describe("parseSharedImportUrl", () => { - test.each([ - "llm-space://shared/gist/threads/abc123", - "https://deer-flow.github.io/llm-space/#/shared/gist/threads/abc123", - ])("accepts supported shared-thread URL %s", (url) => { - expect(parseSharedImportUrl(url)).toEqual({ - connectorId: "gist", - threadId: "abc123", - }); - }); - - test.each([ - "https://example.com/llm-space/#/shared/gist/threads/abc123", - "https://deer-flow.github.io/llm-space/#/not-shared/gist/threads/abc123", - "not a URL", - "", - ])("rejects unrelated URL %s", (url) => { - expect(parseSharedImportUrl(url)).toBeNull(); - }); -}); diff --git a/apps/desktop/src/bun/tools/built-in/fs.test.ts b/apps/desktop/src/bun/tools/built-in/fs.test.ts deleted file mode 100644 index d3b1d6c1..00000000 --- a/apps/desktop/src/bun/tools/built-in/fs.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { bash, createFsBuiltInTools } from "./fs"; - -const BASE_DEPENDENCIES = { - findSkill: () => null, - workspaceRoot: "C:/workspace", -}; - -describe("Bash capability", () => { - test("does not advertise bash when the host cannot resolve it", () => { - expect( - createFsBuiltInTools({ ...BASE_DEPENDENCIES, bashPath: null }).map( - (entry) => entry.tool.name - ) - ).not.toContain("bash"); - }); - - test("returns an actionable error when direct execution has no Bash", () => { - expect(bash("echo hello", undefined, null)).rejects.toThrow( - "Bash is unavailable" - ); - }); - - test("executes the exact resolved path even when it contains spaces", async () => { - const calls: { command: string; args: string[]; timeout?: number }[] = []; - const result = await bash( - "printf hello", - 42, - "C:/Program Files/Git/bin/bash.exe", - (command, args, timeout) => { - calls.push({ command, args, timeout }); - return Promise.resolve({ stdout: "hello", stderr: "", code: 0 }); - } - ); - - expect(calls).toEqual([ - { - command: "C:/Program Files/Git/bin/bash.exe", - args: ["-c", "printf hello"], - timeout: 42, - }, - ]); - expect(result).toEqual({ stdout: "hello", stderr: "", exitCode: 0 }); - }); -}); From 3771aad94acb1ae786988d2a9cb60e9493b23923 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:45:28 +0800 Subject: [PATCH 13/17] fix(generator): normalize source line endings --- .../generator/langgraph/tools/built-in-sources.test.ts | 3 ++- scripts/gen-langgraph-tools.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core/src/generator/langgraph/tools/built-in-sources.test.ts b/packages/core/src/generator/langgraph/tools/built-in-sources.test.ts index 9cdba7b9..d836ac40 100644 --- a/packages/core/src/generator/langgraph/tools/built-in-sources.test.ts +++ b/packages/core/src/generator/langgraph/tools/built-in-sources.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { + normalizeLineEndings, readBuiltinToolSources, readVariablesSource, renderManifest, @@ -30,7 +31,7 @@ describe("built-in tool sources manifest", () => { const committed = await Bun.file( new URL("./built-in-sources.generated.ts", import.meta.url) ).text(); - expect(committed).toBe(rendered); + expect(normalizeLineEndings(committed)).toBe(rendered); }); it("covers the expected built-in tools", () => { diff --git a/scripts/gen-langgraph-tools.ts b/scripts/gen-langgraph-tools.ts index 60e0a108..98c7354a 100644 --- a/scripts/gen-langgraph-tools.ts +++ b/scripts/gen-langgraph-tools.ts @@ -23,6 +23,10 @@ const BUILTIN_DIR = path.join(LANGGRAPH_DIR, "tools/built-in"); const VARIABLES_PY = path.join(LANGGRAPH_DIR, "variables.py"); const OUT_FILE = path.join(LANGGRAPH_DIR, "tools/built-in-sources.generated.ts"); +export function normalizeLineEndings(source: string): string { + return source.replace(/\r\n/g, "\n"); +} + /** Read the `.py` tool files into a name→source map (sorted by name). */ export async function readBuiltinToolSources(): Promise> { const entries = await readdir(BUILTIN_DIR); @@ -30,14 +34,16 @@ export async function readBuiltinToolSources(): Promise> const sources: Record = {}; for (const file of pyFiles) { const name = file.slice(0, -".py".length); - sources[name] = await readFile(path.join(BUILTIN_DIR, file), "utf8"); + sources[name] = normalizeLineEndings( + await readFile(path.join(BUILTIN_DIR, file), "utf8") + ); } return sources; } /** Read the shared `variables.py` (prompt-variable helpers) source. */ export async function readVariablesSource(): Promise { - return readFile(VARIABLES_PY, "utf8"); + return normalizeLineEndings(await readFile(VARIABLES_PY, "utf8")); } /** Render the generated TS module from a name→source map + variables.py. */ From ff63eabb0d697efc33237a901f1dbd05679382b9 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:03:24 +0800 Subject: [PATCH 14/17] fix(windows): make window icon setup reliable --- apps/desktop/scripts/smoke-test-windows.ps1 | 31 ++++++++++++------- .../src/bun/app/windows-window-icon.ts | 16 ++++++++-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/desktop/scripts/smoke-test-windows.ps1 b/apps/desktop/scripts/smoke-test-windows.ps1 index 1f01fc6a..5563f0f9 100644 --- a/apps/desktop/scripts/smoke-test-windows.ps1 +++ b/apps/desktop/scripts/smoke-test-windows.ps1 @@ -287,18 +287,25 @@ try { if (-not $mainWindow) { throw "The installed app did not create the LLM Space main window." } - $smallIcon = [LlmSpaceNativeWindow]::SendMessage( - $mainWindow.MainWindowHandle, - 0x007F, - [IntPtr]::Zero, - [IntPtr]::Zero - ) - $largeIcon = [LlmSpaceNativeWindow]::SendMessage( - $mainWindow.MainWindowHandle, - 0x007F, - [IntPtr]::new(1), - [IntPtr]::Zero - ) + $iconDeadline = [DateTime]::UtcNow.AddSeconds([Math]::Min($TimeoutSeconds, 10)) + $smallIcon = [IntPtr]::Zero + $largeIcon = [IntPtr]::Zero + while ([DateTime]::UtcNow -lt $iconDeadline) { + $smallIcon = [LlmSpaceNativeWindow]::SendMessage( + $mainWindow.MainWindowHandle, + 0x007F, + [IntPtr]::Zero, + [IntPtr]::Zero + ) + $largeIcon = [LlmSpaceNativeWindow]::SendMessage( + $mainWindow.MainWindowHandle, + 0x007F, + [IntPtr]::new(1), + [IntPtr]::Zero + ) + if ($smallIcon -ne [IntPtr]::Zero -and $largeIcon -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 250 + } if ($smallIcon -eq [IntPtr]::Zero -or $largeIcon -eq [IntPtr]::Zero) { throw "The LLM Space window did not load its small and large application icons." } diff --git a/apps/desktop/src/bun/app/windows-window-icon.ts b/apps/desktop/src/bun/app/windows-window-icon.ts index 6456cbb1..fb447646 100644 --- a/apps/desktop/src/bun/app/windows-window-icon.ts +++ b/apps/desktop/src/bun/app/windows-window-icon.ts @@ -12,8 +12,8 @@ const ICON_BIG = 1; export function setWindowsWindowIcon(windowPtr: Pointer): void { if (process.platform !== "win32") return; - const iconPath = path.join(process.cwd(), "..", "Resources", "app.ico"); - if (!existsSync(iconPath)) return; + const iconPath = _resolveWindowsWindowIconPath(); + if (!iconPath) return; try { const user32 = dlopen("user32.dll", { @@ -61,3 +61,15 @@ export function setWindowsWindowIcon(windowPtr: Pointer): void { console.warn("Unable to set the Windows window icon:", error); } } + +function _resolveWindowsWindowIconPath(): string | null { + const mainScript = process.argv[1]; + const candidates = [ + ...(mainScript + ? [path.join(path.dirname(path.resolve(mainScript)), "app.ico")] + : []), + path.join(path.dirname(process.execPath), "..", "Resources", "app.ico"), + path.join(process.cwd(), "..", "Resources", "app.ico"), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} From 968eef5725e239828407ada4a0a1aa0dbd3bf155 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:19:28 +0800 Subject: [PATCH 15/17] ci(windows): upload PR build artifacts --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 444281cf..2b11bb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,18 @@ jobs: # pack:win includes the production renderer build. Verification inspects # the compressed tar without extracting into the checkout, then the smoke # test installs and launches it under an isolated temporary LOCALAPPDATA. - - run: mise run pack:win + - name: Build unsigned Windows x64 Canary + env: + LLM_SPACE_UPDATE_BASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/updates + run: mise run pack:win - run: mise run verify:pack:win - run: mise run smoke:win + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-x64-canary + path: apps/desktop/artifacts/ + if-no-files-found: error + retention-days: 7 From 822c026d8b41e55d46ac081efc8099ef7745f772 Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:46:46 +0800 Subject: [PATCH 16/17] Revert "ci(windows): upload PR build artifacts" This reverts commit 968eef5725e239828407ada4a0a1aa0dbd3bf155. --- .github/workflows/ci.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b11bb25..444281cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,18 +65,8 @@ jobs: # pack:win includes the production renderer build. Verification inspects # the compressed tar without extracting into the checkout, then the smoke # test installs and launches it under an isolated temporary LOCALAPPDATA. - - name: Build unsigned Windows x64 Canary - env: - LLM_SPACE_UPDATE_BASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/updates - run: mise run pack:win + - run: mise run pack:win - run: mise run verify:pack:win - run: mise run smoke:win - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: windows-x64-canary - path: apps/desktop/artifacts/ - if-no-files-found: error - retention-days: 7 From 7cbcb0849782c13122cb26c304ace17de6f0cfef Mon Sep 17 00:00:00 2001 From: urzeye <20869204+urzeye@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:03:34 +0800 Subject: [PATCH 17/17] fix(windows): restore application menu shortcuts --- apps/desktop/src/app/page.tsx | 2 + apps/desktop/src/bun/app/menu.ts | 117 +++++++++++++++++++--------- apps/desktop/src/bun/commands.ts | 4 + apps/desktop/src/commands/index.tsx | 67 ++++++++++++++++ apps/desktop/src/shared/commands.ts | 4 + 5 files changed, 159 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index afdd4506..2d4c8ae8 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -169,6 +169,8 @@ const COMMAND_PALETTE_BLACKLIST = [ "createConnectedTraceProject", "importLangfuseTraceFiles", "syncLangfuseTraceIds", + // Fullscreen remains a native-menu/window shortcut rather than a palette item. + "toggleFullScreen", // Only meaningful from the "ready to install" toast; a bare palette // invocation would silently no-op (or restart mid-work). "applyUpdateAndRestart", diff --git a/apps/desktop/src/bun/app/menu.ts b/apps/desktop/src/bun/app/menu.ts index f12fe5f3..0f3f5a32 100644 --- a/apps/desktop/src/bun/app/menu.ts +++ b/apps/desktop/src/bun/app/menu.ts @@ -8,6 +8,58 @@ import type { Command } from "../../shared/commands"; import { isChineseLocale } from "./locales"; +const IS_WINDOWS = process.platform === "win32"; +const WINDOWS_DISPLAY_ONLY_ACCELERATOR = "DisplayOnly"; + +/** + * Electrobun 1.18.1 builds a Win32 accelerator table but translates key + * messages against the focused child WebView HWND. The keystroke is consumed, + * while the resulting WM_COMMAND never reaches the top-level app window. Keep + * the shortcut visible in Windows menus and let the renderer command bus handle + * it instead. Other platforms retain Electrobun's native accelerators. + */ +function _shortcut(label: string, accelerator: string) { + if (!IS_WINDOWS) return { label, accelerator }; + const display = accelerator + .replace("CommandOrControl", "Ctrl") + .replace("Option", "Alt") + .replace(/Plus$/, "+"); + return { label: `${label}\t${display}` }; +} + +/** + * Electrobun assigns accelerators to edit roles when none is provided. An + * intentionally unparseable value suppresses that Windows-only registration so + * standard editing keystrokes continue through to WebView; menu clicks still + * use the native role. + */ +function _editRole( + role: string, + label: string, + windowsAccelerator: string +): ApplicationMenuItemConfig { + if (!IS_WINDOWS) return { role }; + return { + role, + label: `${label}\t${windowsAccelerator}`, + accelerator: WINDOWS_DISPLAY_ONLY_ACCELERATOR, + }; +} + +function _roleShortcut( + role: string, + label: string, + accelerator: string +): ApplicationMenuItemConfig { + if (!IS_WINDOWS) return { role, accelerator }; + const { label: displayLabel } = _shortcut(label, accelerator); + return { + role, + label: displayLabel, + accelerator: WINDOWS_DISPLAY_ONLY_ACCELERATOR, + }; +} + /** * The app (first) submenu. Its update item is the one dynamic piece: normally * "Check for Updates…"; once an update is downloaded it becomes @@ -24,9 +76,8 @@ function _appSubmenu(updateReady: boolean): ApplicationMenuItemConfig { updateItem, { type: "divider" }, { - label: "Settings...", + ..._shortcut("Settings...", "CommandOrControl+,"), action: "settings", - accelerator: "CommandOrControl+,", }, { type: "divider" }, { role: "hide", accelerator: "CommandOrControl+H" }, @@ -49,16 +100,14 @@ function _buildMenu(updateReady: boolean): ApplicationMenuItemConfig[] { label: "File", submenu: [ { - label: "New File", + ..._shortcut("New File", "CommandOrControl+N"), action: "newThread", - accelerator: "CommandOrControl+N", }, { label: "New from Examples...", action: "newFromExamples" }, { type: "divider" }, { - label: "New Folder", + ..._shortcut("New Folder", "CommandOrControl+Shift+N"), action: "newFolder", - accelerator: "CommandOrControl+Shift+N", }, { type: "divider" }, { label: "Import from Files...", action: "importFiles" }, @@ -70,69 +119,65 @@ function _buildMenu(updateReady: boolean): ApplicationMenuItemConfig[] { { label: "Reveal Workspace Folder", action: "revealWorkspaceFolder" }, { type: "divider" }, { - label: "Close Tab", + ..._shortcut("Close Tab", "CommandOrControl+W"), action: "closeTab", - accelerator: "CommandOrControl+W", }, { label: "Close Others", action: "closeOtherTabs" }, { label: "Close All Tabs", action: "closeAllTabs" }, { type: "divider" }, { - label: "Reopen Closed Tabs", + ..._shortcut("Reopen Closed Tabs", "CommandOrControl+Shift+T"), action: "reopenClosedTabs", - accelerator: "CommandOrControl+Shift+T", }, ], }, { label: "Edit", submenu: [ - { role: "undo" }, - { role: "redo" }, + _editRole("undo", "Undo", "Ctrl+Z"), + _editRole("redo", "Redo", "Ctrl+Y"), { type: "divider" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "pasteAndMatchStyle" }, - { role: "delete" }, - { role: "selectAll" }, + _editRole("cut", "Cut", "Ctrl+X"), + _editRole("copy", "Copy", "Ctrl+C"), + _editRole("paste", "Paste", "Ctrl+V"), + _editRole( + "pasteAndMatchStyle", + "Paste and Match Style", + "Ctrl+Shift+V" + ), + _editRole("delete", "Delete", "Del"), + _editRole("selectAll", "Select All", "Ctrl+A"), ], }, { label: "View", submenu: [ { - label: "Command Palette...", + ..._shortcut("Command Palette...", "CommandOrControl+Shift+P"), action: "commandPalette", - accelerator: "CommandOrControl+Shift+P", }, { type: "divider" }, { - label: "Toggle Sidebar", + ..._shortcut("Toggle Sidebar", "CommandOrControl+B"), action: "toggleSidebar", - accelerator: "CommandOrControl+B", }, { type: "divider" }, { - label: "Reload", + ..._shortcut("Reload", "CommandOrControl+Shift+R"), action: "reload", - accelerator: "CommandOrControl+Shift+R", }, { type: "divider" }, { - label: "Zoom In", + ..._shortcut("Zoom In", "CommandOrControl+Plus"), action: "zoomIn", - accelerator: "CommandOrControl+Plus", }, { - label: "Zoom Out", + ..._shortcut("Zoom Out", "CommandOrControl+-"), action: "zoomOut", - accelerator: "CommandOrControl+-", }, { - label: "Reset Zoom", + ..._shortcut("Reset Zoom", "CommandOrControl+0"), action: "resetZoom", - accelerator: "CommandOrControl+0", }, ], }, @@ -144,17 +189,19 @@ function _buildMenu(updateReady: boolean): ApplicationMenuItemConfig[] { { role: "bringAllToFront" }, { type: "divider" }, { - label: "Select Previous Tab", + ..._shortcut("Select Previous Tab", "CommandOrControl+Option+Left"), action: "selectPreviousTab", - accelerator: "CommandOrControl+Option+Left", }, { - label: "Select Next Tab", + ..._shortcut("Select Next Tab", "CommandOrControl+Option+Right"), action: "selectNextTab", - accelerator: "CommandOrControl+Option+Right", }, { type: "divider" }, - { role: "toggleFullScreen", accelerator: "CommandOrControl+Shift+F" }, + _roleShortcut( + "toggleFullScreen", + "Toggle Full Screen", + "CommandOrControl+Shift+F" + ), ], }, { diff --git a/apps/desktop/src/bun/commands.ts b/apps/desktop/src/bun/commands.ts index d9dac827..bf8d6cb1 100644 --- a/apps/desktop/src/bun/commands.ts +++ b/apps/desktop/src/bun/commands.ts @@ -94,6 +94,10 @@ export function executeCommandInBun( saveZoom(1); return; } + case "toggleFullScreen": { + window.setFullScreen(!window.isFullScreen()); + return; + } case "reload": { window.webview?.executeJavascript("location.reload()"); return; diff --git a/apps/desktop/src/commands/index.tsx b/apps/desktop/src/commands/index.tsx index e22affee..3b1ba241 100644 --- a/apps/desktop/src/commands/index.tsx +++ b/apps/desktop/src/commands/index.tsx @@ -45,6 +45,58 @@ interface CommandContextValue { const CommandContext = createContext(null); +/** + * Resolve app-level Windows shortcuts after Electrobun's broken native menu + * accelerators have been disabled. Editing shortcuts are deliberately omitted + * so inputs and CodeMirror retain the WebView's standard behavior. + */ +function _getWindowsShortcutCommand(event: KeyboardEvent): Command | null { + if (!event.ctrlKey || event.metaKey || event.isComposing) return null; + + if (event.altKey) { + if (event.shiftKey) return null; + if (event.code === "ArrowLeft") { + return { type: "selectPreviousTab", args: {} }; + } + if (event.code === "ArrowRight") { + return { type: "selectNextTab", args: {} }; + } + return null; + } + + switch (event.code) { + case "KeyN": + return event.shiftKey + ? { type: "newFolder", args: {} } + : { type: "newFile", args: {} }; + case "KeyW": + return event.shiftKey ? null : { type: "closeTab", args: {} }; + case "KeyT": + return event.shiftKey ? { type: "reopenClosedTab", args: {} } : null; + case "Comma": + return event.shiftKey ? null : { type: "openSettings", args: {} }; + case "KeyP": + return event.shiftKey ? { type: "openCommandPalette", args: {} } : null; + case "KeyB": + return event.shiftKey ? null : { type: "toggleSidebar", args: {} }; + case "KeyR": + return event.shiftKey ? { type: "reload", args: {} } : null; + case "Equal": + case "NumpadAdd": + return { type: "zoomIn", args: {} }; + case "Minus": + case "NumpadSubtract": + return event.shiftKey ? null : { type: "zoomOut", args: {} }; + case "Digit0": + case "Numpad0": + return event.shiftKey ? null : { type: "resetZoom", args: {} }; + case "KeyF": + return event.shiftKey ? { type: "toggleFullScreen", args: {} } : null; + default: + return null; + } +} + /** * Holds the renderer command registry. Handlers live in a ref-based map keyed by * command type, so components that own the relevant state (tabs, file tree, @@ -78,6 +130,21 @@ export function CommandProvider({ children }: { children: ReactNode }) { }; }, []); + useEffect(() => { + if (!navigator.userAgent.includes("Windows")) return; + + const handleKeyDown = (event: KeyboardEvent) => { + const command = _getWindowsShortcutCommand(event); + if (!command) return; + event.preventDefault(); + event.stopPropagation(); + executeCommand(command); + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [executeCommand]); + const value = useMemo( () => ({ executeCommand, registerCommandHandlers }), [executeCommand, registerCommandHandlers] diff --git a/apps/desktop/src/shared/commands.ts b/apps/desktop/src/shared/commands.ts index 3df1f4c9..8f54cab1 100644 --- a/apps/desktop/src/shared/commands.ts +++ b/apps/desktop/src/shared/commands.ts @@ -257,6 +257,8 @@ export interface ZoomInCommand extends GenericCommand<"zoomIn"> {} export interface ZoomOutCommand extends GenericCommand<"zoomOut"> {} /** Reset the page zoom to 100%. */ export interface ResetZoomCommand extends GenericCommand<"resetZoom"> {} +/** Enter or leave OS-level fullscreen mode. */ +export interface ToggleFullScreenCommand extends GenericCommand<"toggleFullScreen"> {} /** Reload the webview. */ export interface ReloadCommand extends GenericCommand<"reload"> {} @@ -341,6 +343,7 @@ export type Command = | ZoomInCommand | ZoomOutCommand | ResetZoomCommand + | ToggleFullScreenCommand | ReloadCommand | OpenLinkCommand | OpenDocumentCommand @@ -416,6 +419,7 @@ export const COMMAND_META: Record< zoomIn: { label: "Zoom In", target: "bun" }, zoomOut: { label: "Zoom Out", target: "bun" }, resetZoom: { label: "Reset Zoom", target: "bun" }, + toggleFullScreen: { label: "Toggle Full Screen", target: "bun" }, reload: { label: "Reload", target: "bun" }, openLink: { label: "Open Link", target: "bun" }, openDocument: { label: "Documents", target: "bun" },