Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/desktop/bin/paseo.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -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%
44 changes: 44 additions & 0 deletions packages/desktop/scripts/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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(
Expand All @@ -147,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
appPath: appOutDir,
});
}

exports.createWindowsCliExecutable = createWindowsCliExecutable;
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
39 changes: 38 additions & 1 deletion packages/desktop/src/daemon/desktop-packaging.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 });
}
});
});
Loading