Skip to content
45 changes: 37 additions & 8 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,24 @@ const pty = require('node-pty');
const chokidar = require('chokidar');
const { execFile, spawn } = require('child_process');

const OPENSCAD_BIN = process.env.OPENSCAD_BINARY || 'openscad';
// Auto-detect OpenSCAD on Windows if not in PATH
function findOpenSCAD() {
if (process.env.OPENSCAD_BINARY) return process.env.OPENSCAD_BINARY;
if (process.platform === 'win32') {
const candidates = [
'C:\\Program Files\\OpenSCAD\\openscad.exe',
'C:\\Program Files (x86)\\OpenSCAD\\openscad.exe',
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
}
return 'openscad';
}
const OPENSCAD_BIN = findOpenSCAD();
const DEFAULT_SHELL = process.platform === 'win32'
? (process.env.COMSPEC || 'cmd.exe')
: (process.env.SHELL || '/bin/bash');
const STATE_FILE = 'clawscad.json';
const ACTIVE_FILE = 'active.scad';
const MAX_WINDOWS = 4;
Expand Down Expand Up @@ -197,12 +214,14 @@ function openWindow(wsDir) {

initWorkspace(ctx);
loadState(ctx);
startTerminal(ctx);
startFileWatcher(ctx);

win.setTitle(`ClawSCAD — ${ctx.workspaceDir}`);

win.webContents.once('did-finish-load', () => {
// Start terminal here so the renderer is already listening for 'terminal:data'
// and won't miss Claude's initial output.
startTerminal(ctx);
sendCheckpoints(ctx);
if (ctx.state.active && ctx.state.checkpoints[ctx.state.active]) {
const cp = ctx.state.checkpoints[ctx.state.active];
Expand Down Expand Up @@ -330,6 +349,7 @@ function initWorkspace(ctx) {
settings.mcpServers.openscad = {
command: 'npx',
args: ['-y', 'openscad-mcp-server'],
env: { OPENSCAD_BINARY: OPENSCAD_BIN },
};
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
Expand Down Expand Up @@ -628,6 +648,7 @@ function spawnPty(ctx, cmd, args = []) {
rows: 24,
cwd: ctx.workspaceDir,
env: { ...process.env, COLORTERM: 'truecolor' },
...(process.platform === 'win32' && { useConpty: false }),
});
proc.onData((data) => ctxSend(ctx, 'terminal:data', data));
return proc;
Expand All @@ -640,20 +661,24 @@ function spawnPty2(ctx, cmd, args = []) {
rows: 24,
cwd: ctx.workspaceDir,
env: { ...process.env, COLORTERM: 'truecolor' },
...(process.platform === 'win32' && { useConpty: false }),
});
proc.onData((data) => ctxSend(ctx, 'terminal2:data', data));
return proc;
}

function startTerminal(ctx) {
const shell = process.env.SHELL || '/bin/bash';
try {
ctx.ptyProcess = spawnPty(ctx, 'claude', []);
} catch {
ctx.ptyProcess = spawnPty(ctx, shell, []);
ctx.ptyProcess = spawnPty(ctx, DEFAULT_SHELL, []);
}
ctx.ptyProcess.onExit(() => {
ctx.ptyProcess = spawnPty(ctx, shell, []);
try {
ctx.ptyProcess = spawnPty(ctx, 'claude', []);
} catch {
ctx.ptyProcess = spawnPty(ctx, DEFAULT_SHELL, []);
}
ctx.ptyProcess.onExit(() => {});
});
}
Expand All @@ -663,10 +688,14 @@ function restartTerminal(ctx, args = []) {
try {
ctx.ptyProcess = spawnPty(ctx, 'claude', args);
} catch {
ctx.ptyProcess = spawnPty(ctx, process.env.SHELL || '/bin/bash', []);
ctx.ptyProcess = spawnPty(ctx, DEFAULT_SHELL, []);
}
ctx.ptyProcess.onExit(() => {
ctx.ptyProcess = spawnPty(ctx, process.env.SHELL || '/bin/bash', []);
try {
ctx.ptyProcess = spawnPty(ctx, 'claude', []);
} catch {
ctx.ptyProcess = spawnPty(ctx, DEFAULT_SHELL, []);
}
ctx.ptyProcess.onExit(() => {});
});
}
Expand Down Expand Up @@ -721,7 +750,7 @@ ipcMain.handle('terminal2:spawn', (event) => {
try {
ctx.ptyProcess2 = spawnPty2(ctx, 'claude', []);
} catch {
ctx.ptyProcess2 = spawnPty2(ctx, process.env.SHELL || '/bin/bash', []);
ctx.ptyProcess2 = spawnPty2(ctx, DEFAULT_SHELL, []);
}
ctx.ptyProcess2.onExit(() => { ctx.ptyProcess2 = null; });
});
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "AI-powered 3D CAD — OpenSCAD + Claude Code with checkpoint branching",
"main": "main.js",
"scripts": {
"postinstall": "electron-rebuild -f -w node-pty",
"postinstall": "node scripts/postinstall.js",
"build:renderer": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && esbuild renderer.js --bundle --outfile=dist/renderer.js --format=esm --platform=browser --loader:.ttf=file",
"start": "npm run build:renderer && electron .",
"dev": "npm run build:renderer && electron . --dev",
Expand All @@ -21,7 +21,7 @@
"@xterm/xterm": "^5.5.0",
"chokidar": "^3.6.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.0.0",
"node-pty": "^1.1.0",
"three": "^0.170.0"
},
"devDependencies": {
Expand All @@ -44,6 +44,7 @@
"index.html",
"style.css",
"icon.png",
"scripts/**/*",
"dist/**/*",
"node_modules/**/*",
"!node_modules/.cache"
Expand Down
4 changes: 4 additions & 0 deletions renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ term.loadAddon(fitAddon);
const termEl = document.getElementById('terminal');
term.open(termEl);
fitAddon.fit();
term.focus();
termEl.addEventListener('click', () => term.focus());

window.api.onTerminalData((data) => term.write(data));
term.onData((data) => window.api.sendTerminalInput(data));
Expand Down Expand Up @@ -257,6 +259,8 @@ document.getElementById('add-terminal-btn').addEventListener('click', async () =
const termEl2 = document.getElementById('terminal-2');
term2.open(termEl2);
fitAddon2.fit();
term2.focus();
termEl2.addEventListener('click', () => term2.focus());

window.api.onTerminal2Data((data) => { if (term2) term2.write(data); });
term2.onData((data) => window.api.sendTerminal2Input(data));
Expand Down
99 changes: 99 additions & 0 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env node
'use strict';

/**
* postinstall.js
*
* On Windows, node-pty's build fails out of the box for three reasons:
*
* 1. winpty.gyp calls .bat helper scripts without the ".\" prefix that cmd.exe
* requires when NoDefaultCurrentDirectoryInExePath is set (default since
* Windows 10 21H1). The scripts also produce paths with backslashes that
* Python 3 misinterprets as Unicode escape sequences (\U, \t, etc.).
* Fix: replace both shell-out calls with hardcoded values and pre-generate
* the header they would have produced.
*
* 2. binding.gyp enables Spectre mitigation ('/Qspectre'), which requires an
* optional Visual Studio component ("MSVC … Spectre-mitigated libs") that
* most developers don't have installed.
* Fix: disable Spectre mitigation in binding.gyp.
*
* These patches are applied before electron-rebuild runs so that `npm install`
* succeeds on a stock Windows + VS Build Tools setup.
*/

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

if (process.platform === 'win32') {
const nodePtyDir = path.join(__dirname, '..', 'node_modules', 'node-pty');

// ── 1. Pre-generate the version header ──────────────────────────────────
const genDir = path.join(nodePtyDir, 'deps', 'winpty', 'src', 'gen');
const headerPath = path.join(genDir, 'GenVersion.h');
const versionTxt = path.join(nodePtyDir, 'deps', 'winpty', 'VERSION.txt');

fs.mkdirSync(genDir, { recursive: true });
const version = fs.existsSync(versionTxt)
? fs.readFileSync(versionTxt, 'utf8').trim()
: 'unknown';
fs.writeFileSync(
headerPath,
`// AUTO-GENERATED by scripts/postinstall.js\n` +
`const char GenVersion_Version[] = "${version}";\n` +
`const char GenVersion_Commit[] = "none";\n`,
'utf8'
);

// ── 2. Patch winpty.gyp ────────────────────────────────────────────────
// Replace the two shell-out calls with literal values so gyp doesn't invoke
// any .bat files and Python 3 doesn't choke on backslash-escape sequences.
const gypPath = path.join(nodePtyDir, 'deps', 'winpty', 'src', 'winpty.gyp');
if (fs.existsSync(gypPath)) {
const lines = fs.readFileSync(gypPath, 'utf8').split('\n');
const patched = lines.map(line => {
if (line.includes('WINPTY_COMMIT_HASH%') && line.includes('cmd /c')) {
return " 'WINPTY_COMMIT_HASH%': 'none',";
}
if (line.includes('UpdateGenVersion') && line.includes('cmd /c')) {
return " 'gen',";
}
return line;
});
fs.writeFileSync(gypPath, patched.join('\n'), 'utf8');
console.log('[postinstall] Patched winpty.gyp (removed bat script calls).');
}

// ── 3. Patch binding.gyp ──────────────────────────────────────────────
// Disable Spectre mitigation — the mitigated libs are an optional VS
// component and not installed on most developer machines.
const bindingPath = path.join(nodePtyDir, 'binding.gyp');
if (fs.existsSync(bindingPath)) {
const original = fs.readFileSync(bindingPath, 'utf8');
const patched = original.replace(/'SpectreMitigation': 'Spectre'/g, "'SpectreMitigation': 'false'");
if (original !== patched) {
fs.writeFileSync(bindingPath, patched, 'utf8');
console.log('[postinstall] Patched binding.gyp (disabled Spectre mitigation).');
}
}

// ── 4. Patch winpty.gyp — disable Spectre for all winpty targets
// winpty's individual targets each set 'SpectreMitigation': 'Spectre', which
// requires the optional "Spectre-mitigated libs" VS component. Replace with
// 'false' so the build works on a standard VS Build Tools install.
if (fs.existsSync(gypPath)) {
const original = fs.readFileSync(gypPath, 'utf8');
const patched = original.replace(/'SpectreMitigation': 'Spectre'/g, "'SpectreMitigation': 'false'");
if (original !== patched) {
fs.writeFileSync(gypPath, patched, 'utf8');
console.log('[postinstall] Patched winpty.gyp (disabled Spectre mitigation).');
}
}
}

// Use the local binary path so this works both when called directly as
// `node scripts/postinstall.js` and from npm's postinstall hook
// (which adds node_modules/.bin to PATH automatically).
const rebuildBin = path.join(__dirname, '..', 'node_modules', '.bin', 'electron-rebuild');
execSync(`"${rebuildBin}" -f -w node-pty`, { stdio: 'inherit' });