From 337bf4b1cafac275a34bca62da44f69f78d83028 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:12:20 +0800 Subject: [PATCH] fix(terminal): configure ConPTY compatibility on Windows (#2180) --- crates/ide-core/Cargo.toml | 2 +- crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/platform.rs | 29 +++++++++++++++++++++ src-tauri/src/commands/app.rs | 2 ++ src/bindings.ts | 1 + src/features/terminal/embedded.test.ts | 25 ++++++++++++++++-- src/features/terminal/embedded.ts | 3 +++ src/features/terminal/session.test.ts | 32 ++++++++++++++++++++++++ src/features/terminal/session.ts | 3 +++ src/features/terminal/windowsPty.test.ts | 19 ++++++++++++++ src/features/terminal/windowsPty.ts | 21 ++++++++++++++++ src/platform/platformInfo.test.ts | 25 ++++++++++++++++++ src/platform/platformInfo.ts | 3 +++ 13 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 crates/ide-core/src/platform.rs create mode 100644 src/features/terminal/windowsPty.test.ts create mode 100644 src/features/terminal/windowsPty.ts create mode 100644 src/platform/platformInfo.test.ts diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index 086aa84fd..5e854ac05 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -22,4 +22,4 @@ tempfile.workspace = true libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] } +windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] } diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 05c3d5d87..a56559fb0 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod ipc_path; pub mod java_runtime; pub mod jsonc; pub mod output_encoding; +pub mod platform; pub mod process; pub mod project_dir; pub mod project_roots; diff --git a/crates/ide-core/src/platform.rs b/crates/ide-core/src/platform.rs new file mode 100644 index 000000000..4b1f9fd00 --- /dev/null +++ b/crates/ide-core/src/platform.rs @@ -0,0 +1,29 @@ +/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure. +#[cfg(windows)] +pub fn windows_build_number() -> Option { + use windows_sys::Wdk::System::SystemServices::RtlGetVersion; + use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW; + + let mut info = + OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::() as u32, ..Default::default() }; + // SAFETY: `info` is initialized and declares the size of the writable structure. + let status = unsafe { RtlGetVersion(&mut info) }; + (status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber) +} + +#[cfg(not(windows))] +pub fn windows_build_number() -> Option { + None +} + +#[cfg(test)] +mod tests { + #[test] + fn windows_build_is_platform_specific() { + let build = super::windows_build_number(); + #[cfg(windows)] + assert!(build.is_some_and(|build| build > 0)); + #[cfg(not(windows))] + assert_eq!(build, None); + } +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 6e41b38df..a7283a85b 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -14,6 +14,7 @@ use tauri::{Manager, State, Window}; pub struct AppInfo { pub version: String, pub platform: String, + pub windows_build_number: Option, pub config_dir: ide_core::IpcPath, /// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. pub wayland: bool, @@ -28,6 +29,7 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo { AppInfo { version: env!("CARGO_PKG_VERSION").into(), platform: std::env::consts::OS.into(), + windows_build_number: ide_core::platform::windows_build_number(), config_dir: state.config_dir.clone().into(), #[cfg(target_os = "linux")] wayland: crate::linux_window_state::is_wayland(), diff --git a/src/bindings.ts b/src/bindings.ts index 1ac0544a4..9e5459d76 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1861,6 +1861,7 @@ export type AndroidDevice = { export type AppInfo = { version: string, platform: string, + windowsBuildNumber: number | null, configDir: IpcPath, /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ wayland: boolean, diff --git a/src/features/terminal/embedded.test.ts b/src/features/terminal/embedded.test.ts index bfc16a5df..68d527ccb 100644 --- a/src/features/terminal/embedded.test.ts +++ b/src/features/terminal/embedded.test.ts @@ -4,17 +4,22 @@ import { installTerminalKeys } from "./keys"; import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; import { registerAction } from "@/platform/actions"; import { usePlatform } from "@/platform/platformInfo"; +import type { ITerminalOptions } from "@xterm/xterm"; const terminal = vi.hoisted(() => ({ selection: "selected run output", handler: (_e: KeyboardEvent): boolean => true, input: vi.fn(), + options: {} as ITerminalOptions, })); vi.mock("@xterm/xterm", () => ({ Terminal: class { cols = 80; rows = 24; options = {}; + constructor(options: ITerminalOptions) { + terminal.options = options; + } textarea?: HTMLTextAreaElement; hasSelection() { return !!terminal.selection; @@ -56,13 +61,29 @@ vi.mock("./fit", () => ({ settledResize: () => ({ resized() {}, cancel() {} }), })); -beforeEach(() => usePlatform.setState({ os: "windows" })); -afterEach(() => usePlatform.setState({ os: "macos" })); +beforeEach(() => usePlatform.setState({ os: "windows", windowsBuildNumber: null })); +afterEach(() => usePlatform.setState({ os: "macos", windowsBuildNumber: null })); afterEach(() => { document.body.replaceChildren(); vi.restoreAllMocks(); }); +it.each([ + ["windows", 19045, { backend: "conpty", buildNumber: 19045 }], + ["windows", 22631, { backend: "conpty", buildNumber: 22631 }], + ["windows", null, { backend: "conpty", buildNumber: undefined }], + ["macos", null, undefined], + ["linux", null, undefined], +] as const)("configures embedded terminals for %s build %s", (os, windowsBuildNumber, expected) => { + usePlatform.setState({ os, windowsBuildNumber }); + const term = createEmbeddedTerminal(document.createElement("div"), { onData() {}, onResize() {} }); + try { + expect(terminal.options.windowsPty).toEqual(expected); + } finally { + term.dispose(); + } +}); + it("copies embedded Run terminal selections before xterm can send Ctrl+C", () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 27c45e046..120dcddc4 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -17,6 +17,7 @@ import { useProject } from "@/platform/project"; import { usePlatform } from "@/platform/platformInfo"; import { workflowSettings } from "@/platform/workflowSettings"; import { fitScheduler, settledResize } from "./fit"; +import { watchWindowsPty, windowsPty } from "./windowsPty"; import { findTerminalLinks } from "./links"; import { LINK_HANDLER, fontFor, openExternal, openFileLink } from "./session"; import { MIN_CONTRAST_RATIO, readTerminalTheme, remeasureWhenFontsLoad } from "./theme"; @@ -33,6 +34,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal const settings = workflowSettings().terminal; const font = fontFor(settings); const term = new Terminal({ + windowsPty: windowsPty(), linkHandler: LINK_HANDLER, fontFamily: font.family, fontSize: font.size, @@ -76,6 +78,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal opts.onResize(cols, rows); }); const disposables: IDisposable[] = [ + watchWindowsPty(term), term.onData((d) => opts.onData(d)), term.onBinary((d) => opts.onData(d)), term.registerLinkProvider({ diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts index 6bec93120..a9126eef4 100644 --- a/src/features/terminal/session.test.ts +++ b/src/features/terminal/session.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FIT_SETTLE_MS } from "./fit"; import { findTerminalLinks, type TerminalLink } from "./links"; import { openFileLink, resolveStartCwd, TerminalSession } from "./session"; +import { usePlatform } from "@/platform/platformInfo"; const ipc = vi.hoisted(() => ({ call: vi.fn(async (v: unknown) => v), @@ -45,6 +46,37 @@ const fileLink = (text: string): Extract => { return l; }; +describe("Windows PTY metadata", () => { + const initialPlatform = usePlatform.getState(); + afterEach(() => usePlatform.setState(initialPlatform)); + + it.each([ + ["windows", 19045, { backend: "conpty", buildNumber: 19045 }], + ["windows", 22631, { backend: "conpty", buildNumber: 22631 }], + ["windows", null, { backend: "conpty", buildNumber: undefined }], + ["macos", null, {}], + ["linux", null, {}], + ] as const)("configures %s build %s", (os, windowsBuildNumber, expected) => { + usePlatform.setState({ os, windowsBuildNumber }); + const session = new TerminalSession({ key: "pty-options", cwd: "/repo", onChange() {}, onFocus() {} }); + try { + expect(session.term.options.windowsPty).toEqual(expected); + } finally { + session.dispose(); + } + }); + + it("updates a restored terminal when platform info arrives and unsubscribes on disposal", () => { + usePlatform.setState({ os: "windows", windowsBuildNumber: null }); + const session = new TerminalSession({ key: "restored", cwd: "/repo", onChange() {}, onFocus() {} }); + usePlatform.setState({ windowsBuildNumber: 22631 }); + expect(session.term.options.windowsPty).toEqual({ backend: "conpty", buildNumber: 22631 }); + session.dispose(); + usePlatform.setState({ windowsBuildNumber: 19045 }); + expect(session.term.options.windowsPty).toEqual({ backend: "conpty", buildNumber: 22631 }); + }); +}); + describe("openFileLink", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 4bd9ccd89..ef334e5be 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -12,6 +12,7 @@ import { call, commands, isTauri, type TerminalId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { editorService, notifications, registerFontZoomSurface } from "@/workbench/contributions"; import { fitScheduler, settledResize } from "./fit"; +import { watchWindowsPty, windowsPty } from "./windowsPty"; import { installTerminalDrop } from "./drop"; import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; import { ImeCommitGate } from "./imeCommit"; @@ -248,6 +249,7 @@ export class TerminalSession { const settings = workflowSettings().terminal; const font = fontFor(settings); this.term = new Terminal({ + windowsPty: windowsPty(), fontFamily: font.family, fontSize: font.size, lineHeight: font.lineHeight, @@ -275,6 +277,7 @@ export class TerminalSession { this.term.loadAddon(this.searchAddon); this.disposables.push( + watchWindowsPty(this.term), this.searchAddon.onDidChangeResults((r) => { for (const l of this.searchListeners) l({ index: r.resultIndex, count: r.resultCount }); }), diff --git a/src/features/terminal/windowsPty.test.ts b/src/features/terminal/windowsPty.test.ts new file mode 100644 index 000000000..98c20366c --- /dev/null +++ b/src/features/terminal/windowsPty.test.ts @@ -0,0 +1,19 @@ +import { afterEach, expect, it } from "vitest"; +import { windowsPty } from "./windowsPty"; +import { usePlatform } from "@/platform/platformInfo"; +import { useRemote } from "@/platform/remote"; + +const initialPlatform = usePlatform.getState(); +const initialRemote = useRemote.getState(); +afterEach(() => { + useRemote.setState(initialRemote); + usePlatform.setState(initialPlatform); +}); + +it.each(["linux", "windows"])("does not apply the desktop build to a remote %s PTY", (os) => { + usePlatform.setState({ os: "windows", windowsBuildNumber: 22631 }); + useRemote.setState({ + session: { os, host: "test", root: "/repo", target: { kind: "ssh", config: "test" }, commands: [] }, + }); + expect(windowsPty()).toBeUndefined(); +}); diff --git a/src/features/terminal/windowsPty.ts b/src/features/terminal/windowsPty.ts new file mode 100644 index 000000000..68d50c8ac --- /dev/null +++ b/src/features/terminal/windowsPty.ts @@ -0,0 +1,21 @@ +import type { ITerminalOptions, Terminal } from "@xterm/xterm"; +import { usePlatform } from "@/platform/platformInfo"; +import { useRemote } from "@/platform/remote"; + +/** Report the local ConPTY backend even when the OS build is unavailable. */ +export function windowsPty(): ITerminalOptions["windowsPty"] { + const { os, windowsBuildNumber } = usePlatform.getState(); + // Remote PTYs must not inherit the desktop's Windows build or backend. + if (useRemote.getState().session) return undefined; + return os === "windows" ? { backend: "conpty", buildNumber: windowsBuildNumber ?? undefined } : undefined; +} + +/** Restored terminals can be constructed before appInfo answers. */ +export function watchWindowsPty(term: Terminal) { + const unsubscribe = usePlatform.subscribe((next, previous) => { + if (next.os !== previous.os || next.windowsBuildNumber !== previous.windowsBuildNumber) { + term.options.windowsPty = windowsPty() ?? {}; + } + }); + return { dispose: unsubscribe }; +} diff --git a/src/platform/platformInfo.test.ts b/src/platform/platformInfo.test.ts new file mode 100644 index 000000000..16706051d --- /dev/null +++ b/src/platform/platformInfo.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { loadPlatformInfo, usePlatform } from "./platformInfo"; + +const appInfo = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", () => ({ isTauri: () => true, commands: { appInfo } })); + +const initialPlatform = usePlatform.getState(); +afterEach(() => usePlatform.setState(initialPlatform)); + +it.each([ + ["windows", 19045], + ["windows", 22631], + ["macos", null], + ["linux", null], +] as const)("loads %s OS build %s separately from the app version", async (platform, windowsBuildNumber) => { + appInfo.mockResolvedValue({ platform, windowsBuildNumber, version: "0.1.39", wayland: false }); + await loadPlatformInfo(); + expect(usePlatform.getState()).toMatchObject({ os: platform, windowsBuildNumber, version: "0.1.39" }); +}); + +it("keeps platform defaults when the query fails", async () => { + appInfo.mockRejectedValue(new Error("unavailable")); + await loadPlatformInfo(); + expect(usePlatform.getState()).toEqual(initialPlatform); +}); diff --git a/src/platform/platformInfo.ts b/src/platform/platformInfo.ts index 95846480c..063c44162 100644 --- a/src/platform/platformInfo.ts +++ b/src/platform/platformInfo.ts @@ -5,6 +5,7 @@ interface PlatformInfo { /** `macos` | `windows` | `linux` | `web`. */ os: string; version: string | null; + windowsBuildNumber: number | null; /** Native traffic lights overlay the title bar (Tauri, macOS, Overlay style). */ overlayTitleBar: boolean; /** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */ @@ -28,6 +29,7 @@ export const usePlatform = create(() => { return { os, version: null, + windowsBuildNumber: null, overlayTitleBar: isTauri() && os === "macos", framelessTitleBar: isTauri() && (os === "windows" || os === "linux"), wayland: false, @@ -41,6 +43,7 @@ export async function loadPlatformInfo() { usePlatform.setState({ os: info.platform, version: info.version, + windowsBuildNumber: info.windowsBuildNumber, overlayTitleBar: info.platform === "macos", framelessTitleBar: info.platform === "windows" || info.platform === "linux", wayland: info.wayland,