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 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)。 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 00000000..6a57a130 Binary files /dev/null and b/apps/desktop/icon.ico differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6fb46833..4b2c95d2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,9 @@ "hmr": "vite --port 5173", "build:view": "NODE_OPTIONS=--max-old-space-size=4096 vite build", "build:canary": "bun run build:view && electrobun build --env=canary", - "build:stable": "bun run build:view && electrobun build --env=stable" + "build:stable": "bun run build:view && electrobun build --env=stable", + "verify:pack:win": "bun scripts/verify-windows-package.ts", + "smoke:win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/smoke-test-windows.ps1" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -69,6 +71,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/apps/desktop/scripts/post-build.ts b/apps/desktop/scripts/post-build.ts new file mode 100644 index 00000000..93795acd --- /dev/null +++ b/apps/desktop/scripts/post-build.ts @@ -0,0 +1,61 @@ +import { existsSync, renameSync, rmSync } from "node:fs"; +import path from "node:path"; + +import rcedit from "rcedit"; + +import { compileWindowsGuiExecutable } from "./windows-csharp"; +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}`); +} + +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 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: ${binary}`); + } + } + + rmSync(coreLauncher, { force: true }); + renameSync(launcher, coreLauncher); + compileWindowsGuiExecutable({ + source: path.join(import.meta.dir, "windows-launcher.cs"), + output: launcher, + icon, + }); + + for (const binary of [launcher, coreLauncher, bundledBun]) { + await rcedit(binary, { icon }); + } + 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 { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name} in Electrobun build hook.`); + return value; +} diff --git a/apps/desktop/scripts/post-package.ts b/apps/desktop/scripts/post-package.ts new file mode 100644 index 00000000..5b8bcad4 --- /dev/null +++ b/apps/desktop/scripts/post-package.ts @@ -0,0 +1,103 @@ +import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import os from "node:os"; +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") { + const artifactDirectory = _requiredEnv("ELECTROBUN_ARTIFACT_DIR"); + const [installerZip] = readdirSync(artifactDirectory).filter( + (name) => 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]); + 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"); + _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( + `Configured Windows installer progress UI: ${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..5563f0f9 --- /dev/null +++ b/apps/desktop/scripts/smoke-test-windows.ps1 @@ -0,0 +1,377 @@ +[CmdletBinding()] +param( + [string]$ArtifactDirectory, + [int]$ObservationSeconds = 15, + [int]$TimeoutSeconds = 120 +) + +$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." +} +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() +$desktopShortcutRoots = @( + [Environment]::GetFolderPath("Desktop"), + [Environment]::GetFolderPath("CommonDesktopDirectory") +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique +$startMenuShortcutRoots = @( + [Environment]::GetFolderPath("StartMenu"), + [Environment]::GetFolderPath("CommonStartMenu") +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique +$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 { $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) { + $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 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) { + 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." + } + $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 -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)." + } + + $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, $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 + [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) } + $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 ($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 wrapper exited before the app became ready." + } + + $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." + } + $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." + } + 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)) { + 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 + } | Select-Object -First 1 + if (-not $bunAlive) { + 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 + } + + 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 $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) { + 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.ts b/apps/desktop/scripts/verify-windows-package.ts new file mode 100644 index 00000000..ac91f3c9 --- /dev/null +++ b/apps/desktop/scripts/verify-windows-package.ts @@ -0,0 +1,181 @@ +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/launcher-core.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/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.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-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/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/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/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/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..b2d227d3 100644 --- a/apps/desktop/src/bun/app/window.ts +++ b/apps/desktop/src/bun/app/window.ts @@ -12,7 +12,9 @@ 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"; +import { setWindowsWindowIcon } from "./windows-window-icon"; const DEV_SERVER_PORT = 5173; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; @@ -49,15 +51,15 @@ 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, }); + 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..fb447646 --- /dev/null +++ b/apps/desktop/src/bun/app/windows-window-icon.ts @@ -0,0 +1,75 @@ +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 = _resolveWindowsWindowIconPath(); + if (!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); + } +} + +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; +} 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..bf8d6cb1 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; } @@ -83,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/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 }], }, }); } 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.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) { 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/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 : ( 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" }, 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 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" 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. */