fix(terminal): configure ConPTY compatibility on Windows (#2180)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 09:12:20 +08:00
1 джерело 047cac209b
коміт 337bf4b1ca
13 files changed
+163 -3

No files matched your search

+1 -1
Переглянути файл
@@ -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"] }
+1
Переглянути файл
@@ -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;
+29
Переглянути файл
@@ -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<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() 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<u32> {
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);
}
}
+2
Переглянути файл
@@ -14,6 +14,7 @@ use tauri::{Manager, State, Window};
pub struct AppInfo {
pub version: String,
pub platform: String,
pub windows_build_number: Option<u32>,
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(),
+1
Переглянути файл
@@ -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,
+23 -2
Переглянути файл
@@ -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 } });
+3
Переглянути файл
@@ -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({
+32
Переглянути файл
@@ -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<TerminalLink, { kind: "file" }> => {
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());
+3
Переглянути файл
@@ -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 });
}),
+19
Переглянути файл
@@ -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();
});
+21
Переглянути файл
@@ -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 };
}
+25
Переглянути файл
@@ -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);
});
+3
Переглянути файл
@@ -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<PlatformInfo>(() => {
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,