diff --git a/bin/codex-executable.d.mts b/bin/codex-executable.d.mts new file mode 100644 index 0000000..61e7835 --- /dev/null +++ b/bin/codex-executable.d.mts @@ -0,0 +1 @@ +export function codexExecutable(command?: string, env?: NodeJS.ProcessEnv, platform?: string, arch?: string): string | undefined; diff --git a/bin/codex-executable.mjs b/bin/codex-executable.mjs new file mode 100644 index 0000000..e5c8d78 --- /dev/null +++ b/bin/codex-executable.mjs @@ -0,0 +1,27 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// Resolve npm's Windows shim to a native executable: spawn cannot run the +// extensionless shell script or .cmd wrapper without a shell. +export function codexExecutable(command = 'codex', env = process.env, platform = process.platform, arch = process.arch) { + const explicit = command.includes('/') || command.includes('\\'); + const candidates = explicit ? [path.resolve(command)] : (env.PATH || '').split(path.delimiter).map(dir => path.resolve(dir || '.', command)); + for (const candidate of candidates) { + if (platform !== 'win32') { if (existsSync(candidate)) return candidate; continue; } + const exe = candidate.endsWith('.exe') ? candidate : `${candidate}.exe`; + if (existsSync(exe)) return exe; + const dir = path.dirname(candidate); + const modules = path.basename(dir) === '.bin' ? path.dirname(dir) : path.join(dir, 'node_modules'); + const root = path.join(modules, '@openai/codex'); + const target = { x64: 'x86_64', arm64: 'aarch64' }[arch]; + if (!target) continue; + let vendor = path.join(root, 'vendor'); + try { + const require = createRequire(path.join(root, 'package.json')); + vendor = path.join(path.dirname(require.resolve(`@openai/codex-win32-${arch}/package.json`)), 'vendor'); + } catch { /* Older Codex packages bundle vendor directly. */ } + const native = path.join(vendor, `${target}-pc-windows-msvc`, 'bin/codex.exe'); + if (existsSync(native)) return native; + } +} diff --git a/bin/doctor.mjs b/bin/doctor.mjs index 4f1576f..3246bae 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -1,5 +1,6 @@ // Deliberately uses only Node built-ins: diagnostics must work before npm setup. import fs from 'node:fs'; +import { codexExecutable } from './codex-executable.mjs'; import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; @@ -129,7 +130,7 @@ export async function doctor(args) { const candidates = configured ? [configured] : ['codex', path.join(data, 'agents/node_modules/.bin/codex')]; let found = false; for (const candidate of candidates) { - const file = executable(candidate); + const file = codexExecutable(candidate); if (!file) continue; found = true; const result = await command(file, ['--version']); diff --git a/bin/papilot.mjs b/bin/papilot.mjs index 438dcae..566c807 100755 --- a/bin/papilot.mjs +++ b/bin/papilot.mjs @@ -5,6 +5,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { codexExecutable } from './codex-executable.mjs'; import { activeInstallation } from './installation.mjs'; const usage = `Usage: papilot [doctor | demo | codex | service install | service stop] [--port ] [--data ] @@ -87,17 +88,19 @@ async function main() { // as the tested version is used as it is; otherwise that version is installed // once into the data folder. function findCodex() { - if (process.env.PAPILOT_CODEX_BIN) return process.env.PAPILOT_CODEX_BIN; + if (process.env.PAPILOT_CODEX_BIN) return codexExecutable(process.env.PAPILOT_CODEX_BIN) || process.env.PAPILOT_CODEX_BIN; const tested = pkg.devDependencies['@openai/codex']; const version = file => (spawnSync(file, ['--version'], { encoding: 'utf8' }).stdout || '').match(/\d+\.\d+\.\d+/)?.[0] || '0.0.0'; const recent = file => { const [a, b] = [parse(version(file)), parse(tested)]; return (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) >= 0; }; - const onPath = (process.env.PATH || '').split(path.delimiter).map(dir => path.join(dir, 'codex')).find(file => existsSync(file)); + const onPath = codexExecutable(); if (onPath && recent(onPath)) return onPath; - const prefix = path.join(process.env.PAPILOT_DATA, 'agents'), installed = path.join(prefix, 'node_modules/.bin/codex'); - if (existsSync(installed) && recent(installed)) return installed; + const prefix = path.join(process.env.PAPILOT_DATA, 'agents'), installed = codexExecutable(path.join(prefix, 'node_modules/.bin/codex')); + if (installed && recent(installed)) return installed; console.log(`Installing Codex ${tested} for Papilot (first run only)...`); - const result = spawnSync('npm', ['install', '--prefix', prefix, `@openai/codex@${tested}`, '--no-audit', '--no-fund', '--loglevel=error'], { stdio: 'inherit' }); - if (result.status === 0 && existsSync(installed)) return installed; + const npm = process.platform === 'win32' ? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') : 'npm'; + const result = spawnSync(process.platform === 'win32' ? process.execPath : npm, [...(process.platform === 'win32' ? [npm] : []), 'install', '--prefix', prefix, `@openai/codex@${tested}`, '--no-audit', '--no-fund', '--loglevel=error'], { stdio: 'inherit' }); + const downloaded = codexExecutable(path.join(prefix, 'node_modules/.bin/codex')); + if (result.status === 0 && downloaded && recent(downloaded)) return downloaded; console.warn('Codex could not be installed, so Codex tasks are unavailable. Install it with npm install -g @openai/codex and run papilot again.'); return ''; } diff --git a/desktop/README.md b/desktop/README.md index 824db07..4663bac 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -55,3 +55,19 @@ The approved app icon uses the blue-and-white Papilot pen nib on a rounded tile `desktop/assets/icon-composer/` contains unmasked background and foreground SVG layers for a future Icon Composer integration. These are source assets, not a compiled `.icon` project; the current Electron build uses the ICNS above. The DMG uses `desktop/assets/dmg-background.svg`, rendered at 1× and 2× for Retina displays. Run `node scripts/render-dmg.mjs` with `rsvg-convert` installed to regenerate both PNGs. The fonts come from the existing web assets. To update the bundled example PDF after editing its sources, run `node scripts/build-demo-preview.mjs` with TeX Live/MacTeX installed. Release CI uses the checked-in previews and backgrounds. + +## Windows preview + +Windows x64 builds use the same Electron app, with bundled Node, npm and Codex. +Build on Windows with an official Node distribution and run `npm run desktop:build`. +If Node's LICENSE is not installed beside node.exe, set `PAPILOT_NODE_LICENSE` to +its official LICENSE file before building. The NSIS installer is written to +`release/desktop/Papilot-VERSION-win-x64.exe`. It installs for the current user +and creates a desktop shortcut. Local builds are unsigned. + +Git and a full TeX distribution remain external prerequisites for paper editing. +The bundled demo PDF can be read without TeX. This preview has not yet validated +a complete TeX build and review on Windows. + +Run `node scripts/check-windows-local.mjs PATH_TO_PACKAGED_EXE` for the Windows +startup smoke check. Set `PAPILOT_SCREENSHOT` to save a screenshot. diff --git a/desktop/builder.cjs b/desktop/builder.cjs index 87b9173..4b38076 100644 --- a/desktop/builder.cjs +++ b/desktop/builder.cjs @@ -10,6 +10,7 @@ module.exports = { { from: '.desktop/runtime', to: 'runtime' }, { from: 'desktop/assets/icon.png', to: 'desktop/assets/icon.png' }, ], + compression: 'normal', asar: true, npmRebuild: false, artifactName: 'Papilot-${version}-${os}-${arch}.${ext}', @@ -22,6 +23,8 @@ module.exports = { iconSize: 80, iconTextSize: 13, contents: [{ x: 180, y: 218, type: 'file' }, { x: 460, y: 218, type: 'link', path: '/Applications' }], }, + win: { target: ['nsis'], signAndEditExecutable: false }, + nsis: { oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true }, linux: { target: ['AppImage', 'deb'], executableName: 'papilot', category: 'Office', icon: 'desktop/assets/icon.png', maintainer: 'Cui Chen ', syncDesktopName: true, synopsis: 'A workspace for your papers', diff --git a/desktop/main.ts b/desktop/main.ts index de39a5c..06d40f7 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -53,7 +53,7 @@ else { else if (action === 'close') window.close(); }); const root = app.isPackaged ? path.join(process.resourcesPath, 'server') : path.resolve(fileURLToPath(new URL('../..', import.meta.url))); - const node = app.isPackaged ? path.join(process.resourcesPath, 'runtime/bin/node') : process.env.PAPILOT_DESKTOP_NODE; + const node = app.isPackaged ? path.join(process.resourcesPath, process.platform === 'win32' ? 'runtime/bin/node.exe' : 'runtime/bin/node') : process.env.PAPILOT_DESKTOP_NODE; if (!node) throw new Error('Use npm run desktop to start the development app.'); const data = app.getPath('userData'), log = path.join(app.getPath('logs'), 'backend.log'); const env: NodeJS.ProcessEnv = backendEnvironment(node, root, data); diff --git a/desktop/runtime.ts b/desktop/runtime.ts index 1a45071..3386d22 100644 --- a/desktop/runtime.ts +++ b/desktop/runtime.ts @@ -1,18 +1,25 @@ import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; import os from 'node:os'; import path from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; export function backendEnvironment(node: string, root: string, data: string, inherited = process.env) { + // Windows environment keys are case-insensitive, but plain JS objects are not. + // Emit one PATH key so Node cannot select an inherited Path over our value. + const environment = { ...inherited }; + const pathKeys = Object.keys(environment).filter(key => process.platform === 'win32' ? key.toLowerCase() === 'path' : key === 'PATH'); + const inheritedPath = pathKeys.map(key => environment[key] || '').join(path.delimiter); + for (const key of pathKeys) delete environment[key]; const home = os.homedir(); const nvm = path.join(inherited.NVM_DIR || path.join(home, '.nvm'), 'versions/node'); let nodeBins: string[] = []; try { nodeBins = fs.readdirSync(nvm).filter(version => /^v\d+\.\d+\.\d+$/.test(version)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })).map(version => path.join(nvm, version, 'bin')); } catch { /* nvm is optional */ } const directories = [path.dirname(node), path.join(root, 'node_modules/.bin'), - ...(inherited.PATH || '').split(path.delimiter), + ...inheritedPath.split(path.delimiter), ...['.local/bin', '.claude/local', '.kimi-code/bin', '.grok/bin', '.opencode/bin', '.bun/bin', '.npm-global/bin', '.volta/bin', '.asdf/shims'].map(dir => path.join(home, dir)), ...nodeBins, '/Library/TeX/texbin', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; - const env = { ...inherited, PATH: [...new Set(directories.filter(dir => path.isAbsolute(dir)))].join(path.delimiter), + const env = { ...environment, PATH: [...new Set(directories.filter(dir => path.isAbsolute(dir)))].join(path.delimiter), NODE_ENV: 'production', PAPILOT_DESKTOP: '1', PAPILOT_DATA: data, PAPILOT_PORT: '0', PAPILOT_PUBLIC_ORIGIN: '', PAPILOT_ACCOUNT_ORIGIN: '', PAPILOT_TAILSCALE_LOGIN: '' }; // The backend is ordinary Node, even when Electron itself was started by a tool. @@ -28,8 +35,8 @@ export function startBackend(options: { node: string; root: string; data: string const fd = fs.openSync(options.log, 'a', 0o600); let child: ChildProcess; try { - child = spawn(options.node, ['--import', path.join(options.root, 'node_modules/tsx/dist/loader.mjs'), path.join(options.root, 'src/server/start.ts')], { - cwd: options.root, env: options.env, stdio: ['ignore', fd, fd, 'ipc'], detached: process.platform !== 'win32', + child = spawn(options.node, ['--import', pathToFileURL(path.join(options.root, 'node_modules/tsx/dist/loader.mjs')).href, path.join(options.root, 'src/server/start.ts')], { + windowsHide: true, cwd: options.root, env: options.env, stdio: ['ignore', fd, fd, 'ipc'], detached: process.platform !== 'win32', }); } finally { fs.closeSync(fd); } const ready = new Promise((resolve, reject) => { diff --git a/scripts/check-windows-local.mjs b/scripts/check-windows-local.mjs new file mode 100644 index 0000000..700bfa9 --- /dev/null +++ b/scripts/check-windows-local.mjs @@ -0,0 +1,26 @@ +import { _electron as electron } from '../vendor/latex-review/node_modules/@playwright/test/index.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import assert from 'node:assert/strict'; +const packaged=process.argv[2]; +const env={...process.env,PAPILOT_DESKTOP_DATA:process.env.PAPILOT_DESKTOP_DATA || fs.mkdtempSync(path.join(os.tmpdir(),'papilot-win-')), PAPILOT_DESKTOP_NODE:process.execPath,PAPILOT_UPDATE_PROBE:'1'}; +delete env.ELECTRON_RUN_AS_NODE; +const app=await electron.launch({executablePath:packaged||path.resolve('node_modules/electron/dist/electron.exe'),args:packaged?[]:['.desktop/app'],env,timeout:60000}); +try { + const page=await app.firstWindow({timeout:60000}); + await page.waitForURL('http://127.0.0.1:*/',{timeout:60000}); + const origin=new URL(page.url()).origin; + const health=await (await fetch(origin+'/api/health')).json();assert.equal(health.name,'Papilot'); + const state=await (await fetch(origin+'/api/state')).json(); + console.log('HEALTH',health.name,health.version,'PROJECTS',state.projects.length); + assert.equal(state.projects.length,1); + const manuscript=await (await fetch(origin+'/api/projects/'+state.projects[0].id+'/manuscript')).json(); + assert.equal(manuscript.build.status,'ready'); + const pdf=await fetch(origin+'/api/projects/'+state.projects[0].id+'/manuscript.pdf?key='+manuscript.build.key); + assert.equal(pdf.status,200); + assert.equal(Buffer.from(await pdf.arrayBuffer()).subarray(0,5).toString(),'%PDF-'); + await page.waitForTimeout(2500); + if(process.env.PAPILOT_SCREENSHOT) await page.screenshot({path:path.resolve(process.env.PAPILOT_SCREENSHOT)}); + console.log('WINDOW',await page.title(),'SCREENSHOT saved'); +} finally {await app.close();} diff --git a/scripts/desktop.mjs b/scripts/desktop.mjs index 9026f29..74f1901 100644 --- a/scripts/desktop.mjs +++ b/scripts/desktop.mjs @@ -7,7 +7,7 @@ import { spawnSync, spawn } from 'node:child_process'; const root = fileURLToPath(new URL('..', import.meta.url)); process.chdir(root); const require = createRequire(import.meta.url), pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); -const npm = fs.realpathSync(process.env.npm_execpath || path.join(path.dirname(process.execPath), 'npm')); +const npm = fs.realpathSync(process.env.npm_execpath || path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'node_modules/npm/bin/npm-cli.js' : 'npm')); const run = (command, args, cwd = root) => { const result = spawnSync(command, args, { cwd, stdio: 'inherit' }); if (result.error) throw result.error; @@ -27,7 +27,7 @@ if (process.argv[2] === 'dev') { for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal)); child.on('exit', code => process.exit(code ?? 1)); } else if (process.argv[2] === 'package') { - if (!['darwin', 'linux'].includes(process.platform)) throw new Error('Desktop packaging currently supports macOS and Linux. Build on the target OS and CPU architecture.'); + if (!['darwin', 'linux', 'win32'].includes(process.platform)) throw new Error('Desktop packaging currently supports macOS and Linux. Build on the target OS and CPU architecture.'); // Absent Actions secrets arrive as empty strings. electron-builder treats an // empty CSC_LINK as a certificate path, so omit unset signing credentials. for (const key of ['CSC_LINK', 'CSC_KEY_PASSWORD', 'CSC_NAME', 'APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID']) { @@ -39,12 +39,12 @@ if (process.argv[2] === 'dev') { fs.mkdirSync('.desktop/runtime/bin', { recursive: true }); fs.mkdirSync('.desktop/runtime/lib/node_modules', { recursive: true }); const node = fs.realpathSync(process.execPath); - fs.copyFileSync(node, '.desktop/runtime/bin/node'); - fs.chmodSync('.desktop/runtime/bin/node', 0o755); + fs.copyFileSync(node, process.platform === 'win32' ? '.desktop/runtime/bin/node.exe' : '.desktop/runtime/bin/node'); + if (process.platform !== 'win32') fs.chmodSync('.desktop/runtime/bin/node', 0o755); fs.cpSync(path.dirname(path.dirname(npm)), '.desktop/runtime/lib/node_modules/npm', { recursive: true, filter: source => path.basename(source) !== '__pycache__' }); - for (const name of ['npm', 'npx']) fs.symlinkSync(`../lib/node_modules/npm/bin/${name}-cli.js`, `.desktop/runtime/bin/${name}`); - const license = [path.resolve(path.dirname(node), '../LICENSE'), path.resolve(path.dirname(node), '../share/doc/node/LICENSE'), + if (process.platform !== 'win32') for (const name of ['npm', 'npx']) fs.symlinkSync(`../lib/node_modules/npm/bin/${name}-cli.js`, `.desktop/runtime/bin/${name}`); + const license = [path.join(path.dirname(node), 'LICENSE'), process.env.PAPILOT_NODE_LICENSE || '', path.resolve(path.dirname(node), '../LICENSE'), path.resolve(path.dirname(node), '../share/doc/node/LICENSE'), path.resolve(path.dirname(node), '../share/doc/node/LICENSE.md')].find(file => fs.existsSync(file)); if (!license) throw new Error('Use an official Node.js distribution with its LICENSE file to build the app.'); fs.copyFileSync(license, '.desktop/runtime/LICENSE'); @@ -66,5 +66,5 @@ if (process.argv[2] === 'dev') { runNpm(['install', '--omit=dev', '--no-audit', '--no-fund'], path.join(root, '.desktop/server')); runNpm(['ci', '--omit=dev', '--no-audit', '--no-fund'], path.join(root, '.desktop/server/vendor/latex-review')); run(process.execPath, [require.resolve('electron-builder/cli.js'), '--config', 'desktop/builder.cjs', - process.platform === 'darwin' ? '--mac' : '--linux', `--${process.arch}`, '--publish', 'never', ...process.argv.slice(3)]); + process.platform === 'darwin' ? '--mac' : process.platform === 'win32' ? '--win' : '--linux', `--${process.arch}`, '--publish', 'never', ...process.argv.slice(3)]); } else throw new Error('Expected dev or package.'); diff --git a/src/server/codex.ts b/src/server/codex.ts index fc427b4..7b8e157 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -21,7 +21,7 @@ export class Codex extends EventEmitter { } private async launch() { this.stderr = ''; - const child = spawn(this.binary, this.args, { cwd: ROOT, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(this.binary, this.args, { windowsHide: true, cwd: ROOT, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] }); this.child = child; const lines = createInterface({ input: child.stdout }); lines.on('line', line => { try { this.receive(JSON.parse(line)); } catch { /* non-protocol stdout is ignored */ } }); diff --git a/src/server/paths.ts b/src/server/paths.ts index 14c05c1..9fcca69 100644 --- a/src/server/paths.ts +++ b/src/server/paths.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { codexExecutable } from '../../bin/codex-executable.mjs'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -12,4 +13,6 @@ export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || ''; // port its proxy (such as Tailscale Funnel) forwards to. export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, ''); export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275); -export const CODEX = process.env.PAPILOT_CODEX_BIN || [path.join(ROOT, 'node_modules/.bin/codex'), path.join(DATA, 'agents/node_modules/.bin/codex')].find(file => fs.existsSync(file)) || 'codex'; +const codexCandidate = process.env.PAPILOT_CODEX_BIN || [path.join(ROOT, 'node_modules/.bin/codex'), path.join(DATA, 'agents/node_modules/.bin/codex')].find(file => fs.existsSync(file)) || 'codex'; + +export const CODEX = codexExecutable(codexCandidate) || codexCandidate; diff --git a/tests/codex-executable.test.ts b/tests/codex-executable.test.ts new file mode 100644 index 0000000..1e04598 --- /dev/null +++ b/tests/codex-executable.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { codexExecutable } from '../bin/codex-executable.mjs'; + +test('Windows resolves global and local npm Codex packages without executing shell shims', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot codex ')); + try { + const native = path.join(root, 'node_modules/@openai/codex/vendor/x86_64-pc-windows-msvc/bin/codex.exe'); + fs.mkdirSync(path.dirname(native), { recursive: true }); fs.writeFileSync(native, ''); + fs.writeFileSync(path.join(root, 'codex'), '#!/bin/sh'); + assert.equal(codexExecutable('codex', { PATH: root }, 'win32', 'x64'), native); + assert.equal(codexExecutable(path.join(root, 'node_modules/.bin/codex'), {}, 'win32', 'x64'), native); + fs.rmSync(native); + assert.equal(codexExecutable('codex', { PATH: root }, 'win32', 'x64'), undefined); + const exe = path.join(root, 'codex.exe'); fs.writeFileSync(exe, ''); + assert.equal(codexExecutable('codex', { PATH: root }, 'win32', 'x64'), exe); + assert.equal(codexExecutable(exe, {}, 'win32', 'x64'), exe); + assert.equal(codexExecutable('codex', { PATH: root }, 'linux', 'x64'), path.join(root, 'codex')); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('Windows resolves the optional architecture package, including paths with spaces', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot codex ')); + try { + const pkg = path.join(root, 'node_modules/@openai/codex/node_modules/@openai/codex-win32-arm64'); + const native = path.join(pkg, 'vendor/aarch64-pc-windows-msvc/bin/codex.exe'); + fs.mkdirSync(path.dirname(native), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: '@openai/codex-win32-arm64' })); + fs.writeFileSync(native, ''); + assert.equal(codexExecutable('codex', { PATH: root }, 'win32', 'arm64'), native); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/desktop-runtime.test.ts b/tests/desktop-runtime.test.ts new file mode 100644 index 0000000..d6a5df2 --- /dev/null +++ b/tests/desktop-runtime.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { backendEnvironment } from '../desktop/runtime.ts'; + +test('desktop preserves compiler PATH and removes Windows casing duplicates', { skip: process.platform !== 'win32' }, () => { + const compiler = 'C:\\TeXLive\\bin\\windows'; + const other = 'C:\\custom-bin'; + const inherited = { Path: compiler, PATH: other, KEEP: 'yes' }; + const env = backendEnvironment(process.execPath, process.cwd(), process.cwd(), inherited); + assert.deepEqual(Object.keys(env).filter(key => key.toLowerCase() === 'path'), ['PATH']); + assert.ok(env.PATH.split(path.delimiter).includes(compiler)); + assert.ok(env.PATH.split(path.delimiter).includes(other)); + assert.equal((env as NodeJS.ProcessEnv).KEEP, 'yes'); + assert.equal(inherited.Path, compiler); +}); + diff --git a/vendor/latex-review/src/core/process.ts b/vendor/latex-review/src/core/process.ts index 7da3a1d..d886467 100644 --- a/vendor/latex-review/src/core/process.ts +++ b/vendor/latex-review/src/core/process.ts @@ -20,6 +20,7 @@ export function run( ) { const { check = true, ...rest } = options; const result = spawnSync(command, args, { + windowsHide: true, timeout: 300000, maxBuffer: 128 * 1024 * 1024, ...rest, @@ -63,7 +64,8 @@ export function runAsync( const child = spawn(command, args, { cwd: options.cwd, env: options.env, - detached: true, + detached: process.platform !== "win32", + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); running.add(child); @@ -106,6 +108,6 @@ export function runAsync( }); } export function which(command: string): string | null { - const found = run("which", [command], { check: false }); - return found.status === 0 ? found.stdout.toString().trim() : null; + const found = run(process.platform === "win32" ? "where.exe" : "which", [command], { check: false }); + return found.status === 0 ? found.stdout.toString().trim().split(/\r?\n/)[0] : null; }