-
Notifications
You must be signed in to change notification settings - Fork 3.9k
fix(desktop): Windows support for PTY and cross-platform build scripts #6496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- Add Windows rust_pty.dll to library detection - Normalize paths with forward slashes for cross-platform compatibility - Quote commands containing spaces for proper execution - Wrap spawn in try-catch for better error logging - Use path.join for resolving PTY library location
- Install bun-pty for all OS/CPU combinations during build - Copy platform-specific PTY library to dist bin folder
- Add RUST_TARGET fallback for local development without env vars - Use fs API instead of shell commands for Windows compatibility - Derive PTY library name from target triple instead of process.platform - Handle .exe extension for Windows binaries in predev
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds Windows support for PTY (pseudo-terminal) functionality and improves cross-platform build scripts. The main focus is on properly detecting and distributing the platform-specific bun-pty native libraries and handling Windows-specific path conventions.
Key Changes:
- Enhanced PTY library path resolution to support Windows DLL alongside Linux/macOS shared libraries
- Added path normalization and command quoting for Windows compatibility
- Updated build scripts to install and distribute PTY libraries for all target platforms
- Improved desktop build utilities with automatic RUST_TARGET detection and cross-platform filesystem operations
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| packages/opencode/src/pty/index.ts | Adds Windows-specific PTY library loading, path normalization with forward slashes, command quoting for paths with spaces, and error handling for PTY spawn failures |
| packages/opencode/script/build.ts | Adds bun-pty to cross-platform installation and copies appropriate PTY library for each target platform during build |
| packages/desktop/scripts/utils.ts | Adds RUST_TARGET auto-detection fallback, migrates from shell commands to Node.js fs API, and adds PTY library copying alongside binary |
| packages/desktop/scripts/predev.ts | Updates to use new RUST_TARGET export and adds .exe extension handling for Windows binaries |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/desktop/scripts/utils.ts
Outdated
| const ptyLib = isWindows | ||
| ? "rust_pty.dll" | ||
| : isLinux | ||
| ? isArm64 | ||
| ? "librust_pty_arm64.so" | ||
| : "librust_pty.so" | ||
| : isArm64 | ||
| ? "librust_pty_arm64.dylib" | ||
| : "librust_pty.dylib" |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PTY library selection logic is duplicated across three files with slight variations in implementation:
packages/opencode/src/pty/index.tsusesprocess.platformandprocess.archpackages/opencode/script/build.tsusesitem.osanditem.arch- This file uses string matching on Rust target triples
While the logic produces the same results for current supported platforms, this duplication creates a maintenance burden. If a new platform or architecture is added, or if the PTY library naming convention changes, all three locations need to be updated consistently.
Consider extracting this logic into a shared utility function that takes platform and architecture information and returns the appropriate PTY library name.
| export const RUST_TARGET = | ||
| Bun.env.RUST_TARGET || | ||
| Bun.env.TAURI_ENV_TARGET_TRIPLE || | ||
| (process.platform === "win32" | ||
| ? "x86_64-pc-windows-msvc" | ||
| : process.platform === "darwin" | ||
| ? process.arch === "arm64" | ||
| ? "aarch64-apple-darwin" | ||
| : "x86_64-apple-darwin" | ||
| : "x86_64-unknown-linux-gnu") | ||
|
|
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback logic for RUST_TARGET assumes x86_64-unknown-linux-gnu for any non-Windows, non-Darwin platform. This means that less common platforms (like FreeBSD, OpenBSD, or other Unix-like systems) would be incorrectly mapped to Linux. Additionally, the logic doesn't account for 32-bit platforms or other architectures like RISC-V or PowerPC.
While these platforms may not be officially supported based on the SIDECAR_BINARIES configuration, the fallback could silently produce an incorrect target that fails later, rather than failing fast with a clear error message.
Consider either adding validation that the computed target is actually in SIDECAR_BINARIES, or documenting that this fallback is intentionally limited to the supported platforms.
| export const RUST_TARGET = | |
| Bun.env.RUST_TARGET || | |
| Bun.env.TAURI_ENV_TARGET_TRIPLE || | |
| (process.platform === "win32" | |
| ? "x86_64-pc-windows-msvc" | |
| : process.platform === "darwin" | |
| ? process.arch === "arm64" | |
| ? "aarch64-apple-darwin" | |
| : "x86_64-apple-darwin" | |
| : "x86_64-unknown-linux-gnu") | |
| function resolveRustTarget(): string { | |
| const envTarget = Bun.env.RUST_TARGET || Bun.env.TAURI_ENV_TARGET_TRIPLE | |
| let candidate: string | |
| if (envTarget) { | |
| candidate = envTarget | |
| } else { | |
| if (process.platform === "win32") { | |
| candidate = "x86_64-pc-windows-msvc" | |
| } else if (process.platform === "darwin") { | |
| candidate = process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin" | |
| } else if (process.platform === "linux") { | |
| candidate = process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" | |
| } else { | |
| throw new Error( | |
| `Unsupported platform '${process.platform}' with architecture '${process.arch}'. ` + | |
| `Supported Rust targets: ${SIDECAR_BINARIES.map((b) => b.rustTarget).join(", ")}` | |
| ) | |
| } | |
| } | |
| const isSupported = SIDECAR_BINARIES.some((b) => b.rustTarget === candidate) | |
| if (!isSupported) { | |
| throw new Error( | |
| `Unsupported Rust target '${candidate}'. Supported Rust targets: ${SIDECAR_BINARIES.map((b) => b.rustTarget).join(", ")}` | |
| ) | |
| } | |
| return candidate | |
| } | |
| export const RUST_TARGET = resolveRustTarget() |
| const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}` | ||
|
|
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the issue in utils.ts, the binary path construction uses process.platform === "win32" to determine whether to add the .exe extension. This checks the platform where the script is running, not the target platform.
When cross-compiling or when RUST_TARGET is set to a different platform than the current one, this could result in an incorrect binary path. The code should check if RUST_TARGET or sidecarConfig.rustTarget indicates a Windows target instead of checking the current platform.
| const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}` | |
| const targetTriple = (sidecarConfig as { rustTarget?: string }).rustTarget ?? RUST_TARGET | |
| const isWindowsTarget = | |
| typeof targetTriple === "string" | |
| ? targetTriple.toLowerCase().includes("windows") | |
| : process.platform === "win32" | |
| const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${isWindowsTarget ? ".exe" : ""}` |
| if (fs.existsSync(ptySource)) { | ||
| fs.copyFileSync(ptySource, path.join(`dist/${name}/bin`, ptyLib)) | ||
| } |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code silently skips copying the PTY library if the source file doesn't exist (line 163 check). While this may be intentional for development scenarios where the PTY library isn't built, it could hide build issues in production scenarios where the library is expected to exist.
Consider adding a warning log when the PTY library source is not found, or making this behavior configurable based on whether this is a development or production build. This would help developers identify when the PTY library is missing when it should be present.
| let normalizedCommand = command.replaceAll("\\", "/") | ||
| // Quote command if it contains spaces (bun-pty doesn't quote the command path) | ||
| if (normalizedCommand.includes(" ")) { | ||
| normalizedCommand = `"${normalizedCommand}"` | ||
| } |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The path normalization with replaceAll("\\", "/") converts Windows backslashes to forward slashes. While forward slashes are generally accepted by many Windows APIs, this unconditional conversion could cause issues in some edge cases.
More critically, the quoting logic on lines 123-125 has a flaw: it only checks if the normalized command contains spaces, but doesn't verify if the command is already quoted. This could result in double-quoting (e.g., "C:/Program Files/tool.exe" becoming ""C:/Program Files/tool.exe""), which would break command execution.
Consider checking if the command is already quoted before adding quotes, or use a more robust quoting mechanism that handles existing quotes.
packages/desktop/scripts/utils.ts
Outdated
| const dest = `${destDir}/opencode-cli-${target}${target.includes("windows") ? ".exe" : ""}` | ||
| await fs.copyFile(source, dest) | ||
|
|
||
| console.log(`Copied ${source} to ${dest}`) | ||
|
|
||
| const isWindows = target.includes("windows") | ||
| const isLinux = target.includes("linux") | ||
| const isArm64 = target.startsWith("aarch64") | ||
|
|
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The .exe extension is added based on process.platform === "win32", which refers to the platform where the build script is running, not the target platform being built for. This means that if you're cross-compiling (e.g., building a Windows binary on a Linux machine), the .exe extension would not be added, resulting in an incorrect binary filename.
The function should check if the target parameter indicates a Windows target (e.g., target.includes("windows")) rather than checking the current platform.
| const dest = `${destDir}/opencode-cli-${target}${target.includes("windows") ? ".exe" : ""}` | |
| await fs.copyFile(source, dest) | |
| console.log(`Copied ${source} to ${dest}`) | |
| const isWindows = target.includes("windows") | |
| const isLinux = target.includes("linux") | |
| const isArm64 = target.startsWith("aarch64") | |
| const isWindows = target.includes("windows") | |
| const isLinux = target.includes("linux") | |
| const isArm64 = target.startsWith("aarch64") | |
| const dest = `${destDir}/opencode-cli-${target}${isWindows ? ".exe" : ""}` | |
| await fs.copyFile(source, dest) | |
| console.log(`Copied ${source} to ${dest}`) |
| const env = { ...process.env, ...input.env, TERM: "xterm-256color" } as Record<string, string> | ||
| log.info("creating session", { id, cmd: command, args, cwd }) | ||
| let normalizedCommand = command.replaceAll("\\", "/") | ||
| // Quote command if it contains spaces (bun-pty doesn't quote the command path) |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 122 states "bun-pty doesn't quote the command path", but this is somewhat misleading. The issue is not just that bun-pty doesn't quote the path - it's that paths with spaces need to be quoted for the shell to interpret them correctly. The comment should be more specific about why quoting is necessary and under what conditions it's applied.
Consider updating the comment to: "Quote command path if it contains spaces, as bun-pty passes the command directly to the shell without automatic quoting"
| // Quote command if it contains spaces (bun-pty doesn't quote the command path) | |
| // Quote command path if it contains spaces, as bun-pty passes the command directly to the shell without automatic quoting |
packages/opencode/src/pty/index.ts
Outdated
| const libPath = path.join(path.dirname(process.execPath), ptyLib) | ||
| process.env.BUN_PTY_LIB = libPath |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PTY library path is constructed using path.dirname(process.execPath), which assumes the library is in the same directory as the executable. While this is correct for the standard deployment structure, there's no validation that the computed path actually exists or that the library is accessible.
If the deployment structure changes or if the executable is moved without the library, the PTY functionality will silently fail at runtime when bun-pty tries to load the library.
Consider adding a check to verify that the computed library path exists and is accessible, and log a warning or error if it's not found during initialization, rather than waiting for a PTY spawn to fail.
| const pty = lazy(async () => { | ||
| if (!Installation.isLocal()) { | ||
| const path = require( | ||
| `bun-pty/rust-pty/target/release/${ | ||
| process.platform === "win32" | ||
| ? "rust_pty.dll" | ||
| : process.platform === "linux" && process.arch === "x64" | ||
| ? "librust_pty.so" | ||
| : process.platform === "darwin" && process.arch === "x64" | ||
| ? "librust_pty.dylib" | ||
| : process.platform === "darwin" && process.arch === "arm64" | ||
| ? "librust_pty_arm64.dylib" | ||
| : process.platform === "linux" && process.arch === "arm64" | ||
| ? "librust_pty_arm64.so" | ||
| : "" | ||
| }`, | ||
| ) | ||
| process.env.BUN_PTY_LIB = path | ||
| if (!Installation.isLocal() && !process.env.BUN_PTY_LIB) { | ||
| const ptyLib = | ||
| process.platform === "win32" | ||
| ? "rust_pty.dll" | ||
| : process.platform === "linux" | ||
| ? process.arch === "arm64" | ||
| ? "librust_pty_arm64.so" | ||
| : "librust_pty.so" | ||
| : process.arch === "arm64" | ||
| ? "librust_pty_arm64.dylib" | ||
| : "librust_pty.dylib" | ||
|
|
||
| const libPath = path.join(path.dirname(process.execPath), ptyLib) | ||
| process.env.BUN_PTY_LIB = libPath | ||
| } |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The BUN_PTY_LIB environment variable is set in a lazy-loaded function that runs when the pty() function is first called. If multiple calls to pty() happen concurrently before the lazy initialization completes, there could be a race condition where the library path is set multiple times or the library is loaded incorrectly.
While the lazy() function from @opencode-ai/util/lazy likely handles this, it would be worth verifying that it properly handles concurrent calls. Consider adding a comment documenting the thread-safety guarantees of the lazy initialization, or ensure the lazy function implementation is thread-safe.
| Bus.publish(Event.Created, { info }) | ||
| return info | ||
| } catch (e) { | ||
| log.error("failed to spawn pty", { error: e }) |
Copilot
AI
Dec 31, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling logs the error and then re-throws it, which is good practice. However, the error object e is logged directly, which may not serialize well for all error types. Consider logging e instanceof Error ? e.message : String(e) or using a more structured error format to ensure error details are properly captured in logs.
| log.error("failed to spawn pty", { error: e }) | |
| log.error("failed to spawn pty", { | |
| error: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : String(e), | |
| }) |
Summary
Changes
ust_pty.dll detection, normalize paths with forward slashes, quote commands with spaces, add try-catch for better error logging