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
1 change: 1 addition & 0 deletions bin/codex-executable.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function codexExecutable(command?: string, env?: NodeJS.ProcessEnv, platform?: string, arch?: string): string | undefined;
27 changes: 27 additions & 0 deletions bin/codex-executable.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 2 additions & 1 deletion bin/doctor.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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']);
Expand Down
15 changes: 9 additions & 6 deletions bin/papilot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args> | service install | service stop] [--port <number>] [--data <folder>]
Expand Down Expand Up @@ -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 '';
}
Expand Down
16 changes: 16 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions desktop/builder.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand All @@ -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 <i@cuichen.cc>', syncDesktopName: true,
synopsis: 'A workspace for your papers',
Expand Down
2 changes: 1 addition & 1 deletion desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions desktop/runtime.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string>((resolve, reject) => {
Expand Down
26 changes: 26 additions & 0 deletions scripts/check-windows-local.mjs
Original file line number Diff line number Diff line change
@@ -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();}
14 changes: 7 additions & 7 deletions scripts/desktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']) {
Expand All @@ -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');
Expand All @@ -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.');
2 changes: 1 addition & 1 deletion src/server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ } });
Expand Down
5 changes: 4 additions & 1 deletion src/server/paths.ts
Original file line number Diff line number Diff line change
@@ -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)), '../..');
Expand All @@ -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;
Loading