diff --git a/bin/cli.mjs b/bin/cli.mjs index ff79929..e0f7565 100644 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -57,14 +57,20 @@ export async function main(argv){ if (res.review) console.log('Safety:', res.review.decision) if (shouldView){ const html = makePerplexHtml(res.answer, res.sources) - openInElectron(html, policy, 'perplexsearch') + if (!openInElectron(html, policy, 'perplexsearch')) { + console.error('Failed to launch Electron viewer') + } } } else if (cmd === 'gallery'){ const res = await safeImageGallery({ query: opts.input || '' }, ctx) if (!res.safe){ console.error(res.reason || 'unsafe'); process.exitCode = 1; return } console.log('Gallery images:') for (const im of res.images) console.log('-', im.url) - if (shouldView && res.galleryHtml) openInElectron(res.galleryHtml, policy, 'gallery') + if (shouldView && res.galleryHtml) { + if (!openInElectron(res.galleryHtml, policy, 'gallery')) { + console.error('Failed to launch Electron viewer') + } + } } else if (cmd === 'youtube'){ const res = await safeYouTubeSearch({ query: opts.input || '' }, ctx) if (!res.safe){ console.error(res.reason || 'unsafe'); process.exitCode = 1; return } @@ -74,7 +80,9 @@ export async function main(argv){ console.log('Safety:', review.decision) if (shouldView){ const open = await openSafeUrl({ url: res.video.url }, ctx) - if (open.safe) openInElectron(open.embedHtml, policy, 'youtube') + if (open.safe && !openInElectron(open.embedHtml, policy, 'youtube')) { + console.error('Failed to launch Electron viewer') + } } } else { console.error('Unknown command') diff --git a/package.json b/package.json index 4525b5d..9ad0f16 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "test": "COLUMNS=80 vitest run --reporter=dot", "kuttyai": "node ./bin/kuttyai.js", - "pack": "npm pack" + "pack": "npm pack", + "postinstall": "node ./scripts/postinstall-wsl.js" }, "dependencies": { "electron": "^30.0.0", @@ -22,4 +23,4 @@ "engines": { "node": ">=18.18" } -} \ No newline at end of file +} diff --git a/scripts/postinstall-wsl.js b/scripts/postinstall-wsl.js new file mode 100644 index 0000000..37a21ed --- /dev/null +++ b/scripts/postinstall-wsl.js @@ -0,0 +1,18 @@ +import os from 'node:os' +import fs from 'node:fs' +import { spawnSync } from 'node:child_process' + +const isWsl = process.platform === 'linux' && (process.env.WSL_DISTRO_NAME || os.release().toLowerCase().includes('microsoft')) + +if (isWsl && !process.env.KUTTYAI_WSL_INSTALL) { + console.log('WSL detected; reinstalling modules for Windows...') + if (fs.existsSync('node_modules')) { + fs.rmSync('node_modules', { recursive: true, force: true }) + } + const npmCli = process.env.npm_execpath || 'npm' + const result = spawnSync(process.execPath, [npmCli, 'install'], { + stdio: 'inherit', + env: { ...process.env, npm_config_platform: 'win32', KUTTYAI_WSL_INSTALL: '1' } + }) + process.exit(result.status ?? 0) +} diff --git a/tests/viewer.e2e.test.js b/tests/viewer.e2e.test.js index b5b3774..1de6d6c 100644 --- a/tests/viewer.e2e.test.js +++ b/tests/viewer.e2e.test.js @@ -1,8 +1,9 @@ import { describe, it, expect, afterAll } from 'vitest' -import { openInElectronTest } from '../viewer/launch.js' +import { openInElectronTest, resolveElectronBin } from '../viewer/launch.js' let xvfbProc = null let hasXvfb = true +let hasElectron = !!resolveElectronBin() if (!process.env.DISPLAY) { try { const { default: Xvfb } = await import('xvfb') @@ -14,13 +15,17 @@ if (!process.env.DISPLAY) { } } +if (!hasElectron) { + console.warn('Electron binary missing; skipping viewer e2e.') +} + afterAll(async () => { if (xvfbProc) { try { xvfbProc.stopSync() } catch {} } }) -const testFn = hasXvfb ? it : it.skip +const testFn = (hasXvfb && hasElectron) ? it : it.skip describe('Electron viewer e2e (test mode)', () => { testFn('spawns and signals READY with policy', async () => { diff --git a/viewer/electron-main.js b/viewer/electron-main.js index f4f67e9..b716fbd 100644 --- a/viewer/electron-main.js +++ b/viewer/electron-main.js @@ -2,6 +2,7 @@ import { app, BrowserWindow, session } from 'electron' import path from 'node:path' import fs from 'node:fs' +const VIEW_HTML = process.env.KUTTYAI_VIEW_HTML const VIEW_FILE = process.env.KUTTYAI_VIEW_FILE const POLICY = safeParse(process.env.KUTTYAI_POLICY_JSON) || { allowDomains: [], bannedTerms: [] } const READY_FILE = process.env.KUTTYAI_READY_FILE @@ -31,10 +32,13 @@ function createWindow(){ return cb({ cancel:false }) }) - if (VIEW_FILE && fs.existsSync(VIEW_FILE)) win.loadFile(VIEW_FILE) - else { + if (VIEW_HTML) { + win.loadURL(`data:text/html;base64,${VIEW_HTML}`) + } else if (VIEW_FILE && fs.existsSync(VIEW_FILE)) { + win.loadFile(VIEW_FILE) + } else { const tmp = path.join(app.getPath('temp'), 'kuttyai_fallback.html') - fs.writeFileSync(tmp, 'Missing view file', 'utf8') + fs.writeFileSync(tmp, 'Missing view content', 'utf8') win.loadFile(tmp) } diff --git a/viewer/launch.js b/viewer/launch.js index 659cc6c..1b02212 100644 --- a/viewer/launch.js +++ b/viewer/launch.js @@ -1,40 +1,164 @@ -import { spawn } from 'node:child_process' -import path from 'node:path' +import { spawn, spawnSync } from 'node:child_process' import os from 'node:os' import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + +const isWsl = process.platform === 'linux' && (process.env.WSL_DISTRO_NAME || os.release().toLowerCase().includes('microsoft')) + +function usingWinElectron(){ + if (!isWsl) return false + try { + const pkgPath = path.dirname(require.resolve('electron/package.json')) + const bin = fs.readFileSync(path.join(pkgPath, 'path.txt'), 'utf8').trim().toLowerCase() + return bin.endsWith('electron.exe') + } catch { + return false + } +} + +function toWinPath(p){ + try { + const out = spawnSync('wslpath', ['-w', p], { encoding:'utf8' }) + if (out.status === 0) return out.stdout.trim() + } catch {} + return p +} + +let viewerProcess = null + +function setupCleanup(){ + const cleanup = () => { + if (viewerProcess && !viewerProcess.killed){ + try { viewerProcess.kill('SIGKILL') } catch {} + viewerProcess = null + } + } + + process.removeAllListeners('exit') + process.removeAllListeners('SIGINT') + process.removeAllListeners('SIGTERM') + + process.once('exit', cleanup) + process.once('SIGINT', () => { cleanup(); process.exit(130) }) + process.once('SIGTERM', () => { cleanup(); process.exit(143) }) +} + +export function resolveElectronBin(){ + const res = spawnSync('npx', ['--no-install', 'electron', '--version'], { stdio: 'ignore' }) + return res.status === 0 ? 'npx' : null +} export function openInElectron(htmlString, policy={}, viewType='generic'){ - const tmpHtml = path.join(os.tmpdir(), `kuttyai_view_${Date.now()}.html`) - fs.writeFileSync(tmpHtml, htmlString, 'utf8') - const electronBin = process.platform === 'win32' ? 'node_modules/.bin/electron.cmd' : 'node_modules/.bin/electron' - const mainPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'electron-main.js') - const child = spawn(electronBin, [mainPath], { - stdio: 'ignore', - env: { ...process.env, KUTTYAI_VIEW_FILE: tmpHtml, KUTTYAI_VIEW_TYPE: viewType, KUTTYAI_POLICY_JSON: JSON.stringify(policy||{}) }, - detached: true, - cwd: process.cwd() - }) - child.unref() + const electronBin = resolveElectronBin() + if (!electronBin){ + console.error('Electron is not installed; run `npm install` and try again') + return false + } + + const mainPathRaw = path.join(path.dirname(fileURLToPath(import.meta.url)), 'electron-main.js') + const useWin = usingWinElectron() + const mainPath = useWin ? toWinPath(mainPathRaw) : mainPathRaw + + const args = ['electron', mainPath] + if (useWin || isWsl) args.push('--no-sandbox') + + const env = { ...process.env, + KUTTYAI_VIEW_HTML: Buffer.from(htmlString, 'utf8').toString('base64'), + KUTTYAI_VIEW_TYPE: viewType, + KUTTYAI_POLICY_JSON: JSON.stringify(policy||{}), + ELECTRON_DISABLE_SECURITY_WARNINGS: '1' + } + if (isWsl && !env.DISPLAY) env.DISPLAY = ':0' + + try { + const child = spawn(electronBin, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env, + detached: false + }) + + viewerProcess = child + setupCleanup() + + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', d => console.log('Electron:', d.trim())) + + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', d => console.error('Electron stderr:', d.trim())) + + child.on('error', err => { + console.error('Failed to launch Electron:', err.message) + }) + + child.on('exit', code => { + if (code !== 0) console.error(`Electron exited with code ${code}`) + }) + + return true + } catch (e) { + console.error('Failed to launch Electron:', e.message) + return false + } } export function openInElectronTest(htmlString, policy={}, viewType='generic', timeoutMs=8000){ return new Promise((resolve) => { - const tmpHtml = path.join(os.tmpdir(), `kuttyai_view_${Date.now()}.html`) - const readyFile = path.join(os.tmpdir(), `kuttyai_ready_${Date.now()}.txt`) - fs.writeFileSync(tmpHtml, htmlString, 'utf8') - const electronBin = process.platform === 'win32' ? 'node_modules/.bin/electron.cmd' : 'node_modules/.bin/electron' - const mainPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'electron-main.js') - const child = spawn(electronBin, [mainPath], { - stdio: 'ignore', - env: { ...process.env, KUTTYAI_VIEW_FILE: tmpHtml, KUTTYAI_VIEW_TYPE: viewType, KUTTYAI_POLICY_JSON: JSON.stringify(policy||{}), KUTTYAI_READY_FILE: readyFile, ELECTRON_DISABLE_SECURITY_WARNINGS: '1' }, - detached: false, - cwd: process.cwd() - }) + const electronBin = resolveElectronBin() + if (!electronBin){ + console.error('Electron is not installed; run `npm install`') + resolve(false) + return + } + + const readyFileRaw = path.join(os.tmpdir(), `kuttyai_ready_${Date.now()}.txt`) + const mainPathRaw = path.join(path.dirname(fileURLToPath(import.meta.url)), 'electron-main.js') + const useWin = usingWinElectron() + const readyFile = useWin ? toWinPath(readyFileRaw) : readyFileRaw + const mainPath = useWin ? toWinPath(mainPathRaw) : mainPathRaw + + const args = ['electron', mainPath] + if (useWin || isWsl) args.push('--no-sandbox') + + const env = { ...process.env, + KUTTYAI_VIEW_HTML: Buffer.from(htmlString, 'utf8').toString('base64'), + KUTTYAI_VIEW_TYPE: viewType, + KUTTYAI_POLICY_JSON: JSON.stringify(policy||{}), + KUTTYAI_READY_FILE: readyFile, + ELECTRON_DISABLE_SECURITY_WARNINGS: '1' + } + if (isWsl && !env.DISPLAY) env.DISPLAY = ':0' + + let child + try { + child = spawn(electronBin, args, { + stdio: 'ignore', + env, + detached: false + }) + } catch (e) { + console.error('Failed to launch Electron:', e.message) + resolve(false) + return + } + let resolved = false - const cleanup = () => { if (resolved) return; resolved = true; try { child.kill() } catch {}; resolve(true) } - const intv = setInterval(()=>{ - if (fs.existsSync(readyFile)) { clearInterval(intv); clearTimeout(timer); cleanup() } + const cleanup = (ok=true) => { if (resolved) return; resolved = true; try { child.kill() } catch {}; resolve(ok) } + + child.on('error', err => { console.error('Failed to launch Electron:', err.message); cleanup(false) }) + child.on('exit', code => { if (code !== 0) { console.error(`Electron exited with code ${code}`); cleanup(false) } }) + + const intv = setInterval(() => { + if (fs.existsSync(readyFile)) { clearInterval(intv); clearTimeout(timer); cleanup(true) } }, 100) - const timer = setTimeout(()=>{ clearInterval(intv); cleanup() }, timeoutMs) + + const timer = setTimeout(() => { + clearInterval(intv) + console.error('Electron did not signal READY within timeout') + cleanup(false) + }, timeoutMs) }) }