From 8d1ba7e3f39e4d7f0fb43d44f85e48ad1b8644f9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 7 May 2026 14:44:44 +0700 Subject: [PATCH] Fix Windows packaged CLI TTY prompts --- packages/desktop/bin/paseo.cmd | 6 ++- packages/desktop/scripts/after-pack.js | 44 +++++++++++++++++++ .../src/daemon/desktop-packaging.test.ts | 39 +++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd index c0c3c64c2..feed505fd 100644 --- a/packages/desktop/bin/paseo.cmd +++ b/packages/desktop/bin/paseo.cmd @@ -4,12 +4,16 @@ setlocal set "SCRIPT_DIR=%~dp0" set "RESOURCES_DIR=%SCRIPT_DIR%.." set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe" +set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe" if not exist "%APP_EXECUTABLE%" ( echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 exit /b 1 ) +if not exist "%CLI_EXECUTABLE%" ( + set "CLI_EXECUTABLE=%APP_EXECUTABLE%" +) set "ELECTRON_RUN_AS_NODE=1" set "PASEO_NODE_ENV=production" -"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* +"%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* exit /b %errorlevel% diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index 139943763..b44ae6518 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -4,6 +4,9 @@ const path = require("path"); const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); const EXECUTABLE_NAME = "Paseo"; +const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli"; +const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3; +const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c; // electron-builder arch enum → Node.js arch string const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" }; @@ -121,12 +124,50 @@ function fmtMB(bytes) { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } +function setWindowsExecutableSubsystem(filePath, subsystem) { + const fd = fs.openSync(filePath, "r+"); + try { + const dosHeader = Buffer.alloc(64); + fs.readSync(fd, dosHeader, 0, dosHeader.length, 0); + if (dosHeader.toString("ascii", 0, 2) !== "MZ") { + throw new Error(`Invalid Windows executable DOS header: ${filePath}`); + } + + const peHeaderOffset = dosHeader.readUInt32LE(0x3c); + const peSignature = Buffer.alloc(4); + fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset); + if (peSignature.toString("ascii") !== "PE\u0000\u0000") { + throw new Error(`Invalid Windows executable PE header: ${filePath}`); + } + + const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER; + const subsystemBuffer = Buffer.alloc(2); + subsystemBuffer.writeUInt16LE(subsystem); + fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset); + } finally { + fs.closeSync(fd); + } +} + +function createWindowsCliExecutable(appOutDir) { + const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`); + const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`); + + fs.copyFileSync(appExecutable, cliExecutable); + setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI); + console.log(`Created Windows CLI executable: ${cliExecutable}`); +} + exports.default = async function afterPack(context) { const platform = context.electronPlatformName; const arch = ARCH_MAP[context.arch] || process.arch; pruneNativeModules(context.appOutDir, platform, arch); + if (platform === "win32") { + createWindowsCliExecutable(context.appOutDir); + } + if (platform === "linux" || platform === "win32") { if (arch !== process.arch) { console.log( @@ -147,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) { appPath: appOutDir, }); } + +exports.createWindowsCliExecutable = createWindowsCliExecutable; +exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem; diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index fee69ae5e..f81c487d6 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -1,9 +1,12 @@ -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const require = createRequire(import.meta.url); describe("desktop packaging", () => { it("unpacks server zsh shell integration files for external shells", () => { @@ -33,4 +36,38 @@ describe("desktop packaging", () => { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } }); + + it("uses a console-subsystem executable for the bundled Windows CLI shim", () => { + const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8"); + + expect(cmd).toContain("PaseoCli.exe"); + expect(cmd).toContain('"%CLI_EXECUTABLE%"'); + }); + + it("can mark the Windows CLI executable as console subsystem", () => { + const { setWindowsExecutableSubsystem } = require( + join(packageRoot, "scripts", "after-pack.js"), + ) as { + setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void; + }; + const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-")); + const exePath = join(tempDir, "probe.exe"); + const peHeaderOffset = 0x80; + const subsystemOffset = peHeaderOffset + 0x5c; + const bytes = Buffer.alloc(subsystemOffset + 2); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt32LE(peHeaderOffset, 0x3c); + bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii"); + bytes.writeUInt16LE(2, subsystemOffset); + writeFileSync(exePath, bytes); + + try { + setWindowsExecutableSubsystem(exePath, 3); + + const patched = readFileSync(exePath); + expect(patched.readUInt16LE(subsystemOffset)).toBe(3); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); });