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
14 changes: 11 additions & 3 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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')
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,4 +23,4 @@
"engines": {
"node": ">=18.18"
}
}
}
18 changes: 18 additions & 0 deletions scripts/postinstall-wsl.js
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 7 additions & 2 deletions tests/viewer.e2e.test.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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 () => {
Expand Down
10 changes: 7 additions & 3 deletions viewer/electron-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, '<!doctype html><html><body>Missing view file</body></html>', 'utf8')
fs.writeFileSync(tmp, '<!doctype html><html><body>Missing view content</body></html>', 'utf8')
win.loadFile(tmp)
}

Expand Down
180 changes: 152 additions & 28 deletions viewer/launch.js
Original file line number Diff line number Diff line change
@@ -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)
})
}