diff --git a/main.js b/main.js index 7822f95..cc35d6f 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,7 @@ const { pathToFileURL } = require('url'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const { spawn } = require('child_process'); const PerformanceMonitor = require('./performance-monitor'); const GPUFallback = require('./gpu-fallback'); const GPUConfig = require('./gpu-config'); @@ -909,7 +910,7 @@ ipcMain.handle('save-image-from-url', async (event, { url }) => { // ========================= // In-memory download registry -const downloads = new Map(); // id -> { id, url, filename, savePath, totalBytes, receivedBytes, state, startedAt, mime, canResume, paused } +const downloads = new Map(); // id -> { id, url, filename, savePath, totalBytes, receivedBytes, state, startedAt, mime, canResume, paused, scan? } function broadcastToAll(channel, payload) { try { @@ -951,7 +952,8 @@ function registerDownloadHandling(ses) { startedAt: Date.now(), mime, canResume: false, - paused: false + paused: false, + scan: { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' } }; downloads.set(id, { ...info, item }); const payload = { ...info }; @@ -975,7 +977,7 @@ function registerDownloadHandling(ses) { }); }); - item.once('done', (e, state) => { + item.once('done', async (e, state) => { const d = downloads.get(id) || {}; const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted'); const final = { @@ -988,11 +990,34 @@ function registerDownloadHandling(ses) { state: finalState, startedAt: d.startedAt || Date.now(), endedAt: Date.now(), - mime + mime, + scan: d.scan || { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' } }; // Store minimal object; drop live item ref downloads.set(id, final); broadcastToAll('downloads-done', final); + + // Kick off a malware scan on Windows if the download completed and path exists + if (finalState === 'completed' && final.savePath && process.platform === 'win32') { + try { + // Update to scanning state and broadcast + const cur = downloads.get(id) || final; + cur.scan = { ...(cur.scan || {}), status: 'scanning', engine: 'Windows Defender' }; + downloads.set(id, cur); + broadcastToAll('downloads-scan-started', { id, savePath: final.savePath }); + + const result = await scanFileForMalware(final.savePath); + const updated = downloads.get(id) || cur; + updated.scan = result; + downloads.set(id, updated); + broadcastToAll('downloads-scan-result', { id, scan: result }); + } catch (scanErr) { + const updated = downloads.get(id) || final; + updated.scan = { status: 'error', engine: 'Windows Defender', details: String(scanErr && scanErr.message || scanErr) }; + downloads.set(id, updated); + broadcastToAll('downloads-scan-result', { id, scan: updated.scan }); + } + } }); } catch (err) { console.error('will-download handler error:', err); @@ -1039,7 +1064,8 @@ ipcMain.handle('downloads-get-all', () => { totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0, state: rest.state || 'in-progress', paused: item.isPaused?.() || false, - canResume: item.canResume?.() || false + canResume: item.canResume?.() || false, + scan: rest.scan || { status: process.platform === 'win32' ? 'pending' : 'unavailable', engine: process.platform === 'win32' ? 'Windows Defender' : 'none' } }; } return rest; @@ -1062,6 +1088,46 @@ ipcMain.handle('downloads-action', async (event, { id, action }) => { case 'cancel': if (item && d.state === 'in-progress') item.cancel?.(); return true; + case 'delete-file': { + if (d.savePath) { + try { + await fs.promises.unlink(d.savePath); + // Mark entry as deleted (custom state) and clear savePath + const updated = { ...d, state: d.state === 'completed' ? 'deleted' : d.state, savePath: null }; + downloads.set(id, updated); + broadcastToAll('downloads-updated', { id, state: updated.state, savePath: null }); + return true; + } catch (e) { + console.error('Failed to delete file:', e); + return false; + } + } + return false; + } + case 'rescan': { + if (d.savePath && process.platform === 'win32') { + try { + const cur = downloads.get(id) || d; + cur.scan = { status: 'scanning', engine: 'Windows Defender' }; + downloads.set(id, cur); + broadcastToAll('downloads-scan-started', { id, savePath: d.savePath }); + const result = await scanFileForMalware(d.savePath); + const updated = downloads.get(id) || cur; + updated.scan = result; + downloads.set(id, updated); + broadcastToAll('downloads-scan-result', { id, scan: result }); + return true; + } catch (e) { + console.error('Rescan failed:', e); + const updated = downloads.get(id) || d; + updated.scan = { status: 'error', engine: 'Windows Defender', details: String(e && e.message || e) }; + downloads.set(id, updated); + broadcastToAll('downloads-scan-result', { id, scan: updated.scan }); + return false; + } + } + return false; + } case 'open-file': if (d.savePath) { await shell.openPath(d.savePath); @@ -1086,8 +1152,79 @@ ipcMain.handle('downloads-action', async (event, { id, action }) => { // IPC: clear completed entries from the registry (keeps in-progress) ipcMain.handle('downloads-clear-completed', () => { for (const [id, d] of downloads.entries()) { - if (d.state === 'completed' || d.state === 'cancelled') downloads.delete(id); + if (d.state === 'completed' || d.state === 'cancelled' || d.state === 'deleted') downloads.delete(id); } broadcastToAll('downloads-cleared'); return true; }); + +// --------------------------- +// Malware scan helpers (Windows Defender) +// --------------------------- +async function findDefenderMpCmdRun() { + if (process.platform !== 'win32') return null; + const candidates = []; + const programData = process.env['ProgramData']; + if (programData) { + const platformDir = path.join(programData, 'Microsoft', 'Windows Defender', 'Platform'); + try { + const entries = await fs.promises.readdir(platformDir, { withFileTypes: true }); + const versions = entries.filter(e => e.isDirectory()).map(e => e.name); + // Sort versions descending (simple lex sort approximates ok as versions are zero-padded; fallback to reverse chronological by stats) + versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); + for (const v of versions) { + candidates.push(path.join(platformDir, v, 'MpCmdRun.exe')); + } + } catch {} + } + const programFiles = process.env['ProgramFiles'] || 'C://Program Files'; + candidates.push(path.join(programFiles, 'Windows Defender', 'MpCmdRun.exe')); + candidates.push(path.join(programFiles, 'Microsoft Defender', 'MpCmdRun.exe')); + for (const c of candidates) { + try { + await fs.promises.access(c, fs.constants.X_OK | fs.constants.R_OK); + return c; + } catch {} + } + return null; +} + +async function scanFileForMalware(filePath) { + if (process.platform !== 'win32') { + return { status: 'unavailable', engine: 'none', details: 'Malware scanning is only available on Windows with Microsoft Defender.' }; + } + try { + // Ensure file exists + await fs.promises.access(filePath, fs.constants.R_OK); + } catch { + return { status: 'error', engine: 'Windows Defender', details: 'File not found for scanning.' }; + } + const exe = await findDefenderMpCmdRun(); + if (!exe) { + return { status: 'unavailable', engine: 'Windows Defender', details: 'Microsoft Defender command-line scanner not found.' }; + } + + return await new Promise((resolve) => { + const args = ['-Scan', '-ScanType', '3', '-File', filePath]; + let stdout = ''; + let stderr = ''; + const child = spawn(exe, args, { windowsHide: true }); + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('error', (err) => { + resolve({ status: 'error', engine: 'Windows Defender', details: 'Failed to run scanner: ' + String(err && err.message || err) }); + }); + child.on('close', (code) => { + const out = (stdout + '\n' + stderr).toLowerCase(); + // Heuristics: exit code 2 indicates threats found; also parse output + const infected = code === 2 || /threat|infected|malware|found\s*:\s*[1-9]/i.test(stdout) || /threat|infected|malware/.test(stderr); + if (infected) { + resolve({ status: 'infected', engine: 'Windows Defender', details: stdout || stderr, exitCode: code }); + } else if (code === 0 || /no threats/.test(out) || /found\s*:\s*0/.test(out)) { + resolve({ status: 'clean', engine: 'Windows Defender', details: stdout || 'No threats found.', exitCode: code }); + } else { + resolve({ status: 'error', engine: 'Windows Defender', details: (stdout || stderr || 'Unknown scan result') + ` (code ${code})`, exitCode: code }); + } + }); + }); +} diff --git a/plugins/nebot/page.js b/plugins/nebot/page.js index 97be0cb..dea32cd 100644 --- a/plugins/nebot/page.js +++ b/plugins/nebot/page.js @@ -196,14 +196,24 @@ if(!chat){ return; } chat.messages.forEach(m=>{ const div = h('div',{class:'msg '+m.role}); - div.innerHTML = '
'+renderMarkdown(m.content)+'
'; - - // Enhance links for security + const mdEl = h('div', { class: 'markdown' }); + // If libs are ready, render now; otherwise, show plain text and mark for deferred upgrade + if (window.marked && window.DOMPurify) { + mdEl.innerHTML = renderMarkdown(m.content); + } else { + mdEl.textContent = m.content || ''; + mdEl.dataset.raw = m.content || ''; + deferredMarkdown.add(mdEl); + scheduleDeferredMarkdownCheck(); + } + div.appendChild(mdEl); + + // Enhance links for security (in case already rendered) div.querySelectorAll('a[href]').forEach(a => { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }); - + els.messages.appendChild(div); }); els.messages.scrollTop = els.messages.scrollHeight; @@ -308,6 +318,8 @@ typeNext(); } + // Keep a registry of handlers so we can remove previous listeners reliably + const streamHandlers = new Map(); function subscribeStream(id){ const channel = 'ollama-chat:stream:' + id; console.log('[Nebot Page] Subscribing to stream channel:', channel); @@ -316,9 +328,12 @@ typingQueue = []; isTyping = false; - // Remove any existing listeners for this channel + // Remove any existing listener registered earlier for this channel if (window.electronAPI && window.electronAPI.removeListener) { - window.electronAPI.removeListener(channel, handleStreamPayload); + const prev = streamHandlers.get(channel); + if (prev) { + try { window.electronAPI.removeListener(channel, prev); } catch {} + } } function handleStreamPayload(...args) { @@ -398,6 +413,7 @@ if (window.electronAPI && window.electronAPI.on) { console.log('[Nebot Page] Setting up stream listener via electronAPI'); window.electronAPI.on(channel, handleStreamPayload); + streamHandlers.set(channel, handleStreamPayload); } else { console.warn('[Nebot Page] electronAPI.on not available for stream subscription'); } @@ -583,4 +599,24 @@ initializeSettings().then(() => { refreshList().then(()=>{ if(state.chats[0]) openChat(state.chats[0].id); }); }); + + // Listen for title updates from main (auto-generated titles) + try { + if (window.electronAPI && typeof window.electronAPI.on === 'function') { + window.electronAPI.on('ollama-chat:chat-updated', (payload) => { + const data = payload || {}; + const { id, title } = data; + if (!id || !title) return; + // Update local state and rerender list + const item = state.chats.find(c => c.id === id); + if (item) { + item.title = title; + renderChatList(); + } else { + // Fallback: refresh list from disk if we don't have it + refreshList(); + } + }); + } + } catch (e) { console.warn('[Nebot Page] failed to attach chat-updated listener', e); } })(); diff --git a/plugins/nebot/renderer-preload.js b/plugins/nebot/renderer-preload.js index 0fc336e..a652fd2 100644 --- a/plugins/nebot/renderer-preload.js +++ b/plugins/nebot/renderer-preload.js @@ -7,7 +7,10 @@ try { marked = require('marked'); hljs = require('highlight.js'); createDOMPurify = require('dompurify'); - DOMPurify = createDOMPurify(window); + // Defer DOMPurify creation until DOM is ready to avoid early failures in some contexts + try { + DOMPurify = createDOMPurify(window); + } catch {} marked.setOptions({ breaks: true, highlight(code, lang) { @@ -24,15 +27,38 @@ try { // Expose to page context so page.html no longer needs CDN scripts try { if (typeof window !== 'undefined') { + // Note: with contextIsolation enabled, assigning to window does not expose to main world. + // Keep assignments for same-world consumers, but also expose explicitly via contextBridge below. window.marked = marked; window.DOMPurify = DOMPurify; window.hljs = hljs; } } catch {} + // Explicitly expose to main world so internal pages (browser://nebot) can use these libs + try { + if (marked) contextBridge.exposeInMainWorld('marked', marked); + if (hljs) contextBridge.exposeInMainWorld('hljs', hljs); + if (DOMPurify) contextBridge.exposeInMainWorld('DOMPurify', DOMPurify); + } catch {} } catch (e) { // If libs aren't available yet, we'll gracefully render as plain text. } +// If DOMPurify wasn't ready, create and expose it after DOM is ready +try { + window.addEventListener('DOMContentLoaded', () => { + try { + if (!DOMPurify && createDOMPurify) { + DOMPurify = createDOMPurify(window); + } + if (DOMPurify) { + try { contextBridge.exposeInMainWorld('DOMPurify', DOMPurify); } catch {} + try { window.DOMPurify = DOMPurify; } catch {} + } + } catch {} + }); +} catch {} + const pluginId = 'ollama-chat'; // Expose minimal API for page scripts (optional) diff --git a/preload.js b/preload.js index 2ff1789..b27404d 100644 --- a/preload.js +++ b/preload.js @@ -129,7 +129,9 @@ contextBridge.exposeInMainWorld('downloadsAPI', { onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)), onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)), onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)), - onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler) + onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler), + onScanStarted: (handler) => ipcRenderer.on('downloads-scan-started', (_e, payload) => handler(payload)), + onScanResult: (handler) => ipcRenderer.on('downloads-scan-result', (_e, payload) => handler(payload)) }); // ---------------------------------------- diff --git a/renderer/downloads.html b/renderer/downloads.html index 1c51366..020130f 100644 --- a/renderer/downloads.html +++ b/renderer/downloads.html @@ -21,6 +21,10 @@ .row { display: flex; gap: 12px; justify-content: space-between; align-items: center; } .empty { color: #888; font-style: italic; padding: 20px; text-align: center; } .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } + .scan { font-size: 12px; } + .scan.bad { color: #f87171; } + .scan.good { color: #34d399; } + .scan.pending { color: #fbbf24; } @@ -46,8 +50,28 @@ return (n/Math.pow(1024,i)).toFixed( i===0 ? 0 : 1 ) + ' ' + u[i]; } + function esc(s) { + return (s || '').replace(/[&<>"']/g, (c) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[c]); + } + function rowHtml(d){ const pct = d.totalBytes > 0 ? Math.min(100, Math.round((d.receivedBytes||0) * 100 / d.totalBytes)) : 0; + const scan = d.scan || { status: 'unavailable' }; + const isInfected = scan.status === 'infected'; + const isScanning = scan.status === 'scanning'; + const scanCls = scan.status === 'infected' ? 'scan bad' : (scan.status === 'clean' ? 'scan good' : (scan.status==='scanning'?'scan pending':'scan')); + const scanText = scan.status === 'infected' ? `Threat detected (${scan.engine||''})` : + scan.status === 'clean' ? `Scanned clean (${scan.engine||''})` : + scan.status === 'scanning' ? `Scanning... (${scan.engine||''})` : + scan.status === 'pending' ? `Queued for scan (${scan.engine||''})` : + scan.status === 'error' ? `Scan error${scan.details?': '+esc(scan.details):''}` : + 'Scan unavailable'; return `
${d.filename}
@@ -56,13 +80,16 @@ ` : ` - + + ${isInfected ? `` : ''} + ${d.state!=='in-progress' ? `` : ''} `}
${d.state} · ${fmtBytes(d.receivedBytes||0)} / ${fmtBytes(d.totalBytes||0)} + · ${scanText}
@@ -102,7 +129,9 @@ // For simplicity now, refresh list refresh(); }); - api.onDone(()=> refresh()); + api.onDone(()=> refresh()); + api.onScanStarted(()=> refresh()); + api.onScanResult(()=> refresh()); api.onCleared(()=> refresh()); refresh(); diff --git a/renderer/insecure.html b/renderer/insecure.html new file mode 100644 index 0000000..96bb670 --- /dev/null +++ b/renderer/insecure.html @@ -0,0 +1,84 @@ + + + + +Connection Not Secure + + + + +
+

+ + Connection Not Secure http +

+

You’re about to visit a page using HTTP (unencrypted). Information you send or view can potentially be intercepted or modified. If this is a site you trust and you understand the risks, you can continue anyway.

+
+ +
+ + + +
+
Nebula Secure Navigation Interstitial
+
+ + + diff --git a/renderer/script.js b/renderer/script.js index 071dc25..9023e4a 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -52,7 +52,9 @@ urlBox.addEventListener('keydown', (e) => { let tabs = []; let activeTabId = null; -const allowedInternalPages = ['settings', 'home', 'downloads', 'nebot']; +const allowedInternalPages = ['settings', 'home', 'downloads', 'nebot', 'insecure']; +// Session-scoped allowlist of HTTP hosts the user explicitly chose to proceed with. +const insecureBypassedHosts = new Set(); let pluginPages = []; // { id, file, fileUrl, pluginId } let pluginPagesReady = false; const pendingInternalNavigations = []; @@ -65,6 +67,16 @@ window.addEventListener('message', (e) => { if (data.type === 'open-internal-page' && typeof data.url === 'string') { console.log('[DEBUG] Message request to open internal page:', data.url); createTab(data.url); + } else if (data.type === 'navigate' && typeof data.url === 'string') { + // Fallback navigation from pages (like insecure.html) when electronAPI.sendToHost is unavailable + try { + if (data.opts && data.opts.insecureBypass && /^http:\/\//i.test(data.url)) { + const h = new URL(data.url).hostname; + insecureBypassedHosts.add(h); + } + } catch {} + urlBox.value = data.url; + navigate(); } } catch (err) { console.warn('[DEBUG] open-internal-page handler error', err); @@ -300,8 +312,15 @@ function createTab(inputUrl) { if (e.channel === 'navigate' && e.args[0]) { const targetUrl = e.args[0]; const opts = e.args[1] || {}; + // If user accepted insecure warning, record host to bypass for session + try { + if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { + const h = new URL(targetUrl).hostname; + insecureBypassedHosts.add(h); + } + } catch {} if (opts.newTab) { - createTab(targetUrl); + createTab(targetUrl); } else { urlBox.value = targetUrl; navigate(); @@ -338,7 +357,10 @@ try { window.createTab = createTab; } catch {} function resolveInternalUrl(url) { console.log('[DEBUG] resolveInternalUrl called with:', url); if (url.startsWith('browser://')) { - const page = url.replace('browser://', ''); + // Support query / hash on internal pages (e.g., browser://insecure?target=...) + const tail = url.replace('browser://', ''); + const page = tail.split(/[?#]/)[0]; + const suffix = tail.slice(page.length); // includes ? and/or # if present console.log('[DEBUG] Extracted page:', page); // Fast path: if user typed browser://nebot and plugin page exists, return immediately if (page === 'nebot') { @@ -358,14 +380,14 @@ function resolveInternalUrl(url) { console.log('[DEBUG] Resolving browser://' + page, 'plug:', plug); if (plug && (plug.fileUrl || plug.file)) { // Prefer pre-built fileUrl for correctness across platforms - const resolved = plug.fileUrl ? plug.fileUrl : (plug.file.startsWith('file://') ? plug.file : 'file://' + plug.file.replace(/\\/g,'/')); - console.log('[DEBUG] Resolved plugin page', page, '->', resolved); - return resolved; + const resolved = plug.fileUrl ? plug.fileUrl : (plug.file.startsWith('file://') ? plug.file : 'file://' + plug.file.replace(/\\/g,'/')); + console.log('[DEBUG] Resolved plugin page', page, '->', resolved); + return resolved + suffix; } // Fallback: built-in renderer copy (e.g., renderer/nebot.html) console.log('[DEBUG] Using fallback for page:', page); - if (page === 'nebot') return 'nebot.html'; - return `${page}.html`; + if (page === 'nebot') return 'nebot.html' + suffix; + return `${page}.html${suffix}`; } console.log('[DEBUG] Page not in allowedInternalPages, returning 404'); return '404.html'; @@ -415,6 +437,35 @@ function performNavigation(input, originalInputForHistory) { console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'tab.isHome:', tab.isHome, 'isInternal:', isInternal); + // Intercept plain HTTP (not HTTPS) navigations (excluding localhost / 127.* / internal pages) + try { + if (!isInternal && /^http:\/\//i.test(resolved)) { + const u = new URL(resolved); + const host = u.hostname; + const isLoopback = /^(localhost|127\.0\.0\.1|::1)$/.test(host); + if (!isLoopback && !insecureBypassedHosts.has(host)) { + const encoded = encodeURIComponent(resolved); + // Directly load insecure.html (avoid custom scheme so OS doesn't try to resolve an external handler) + const interstitial = `insecure.html?target=${encoded}`; + // For a fresh home tab, convert directly to webview showing the interstitial + if (tab.isHome) { + convertHomeTabToWebview(tab.id, originalInputForHistory, interstitial); + return; + } + // Navigate existing webview to interstitial instead + const webviewExisting = document.getElementById(`tab-${activeTabId}`); + if (webviewExisting) webviewExisting.src = interstitial; + tab.history = tab.history.slice(0, tab.historyIndex + 1); + tab.history.push(originalInputForHistory); + tab.historyIndex++; + tab.url = originalInputForHistory; + scheduleRenderTabs(); + scheduleUpdateNavButtons(); + return; + } + } + } catch (e) { debug('[DEBUG] HTTP interception error', e); } + if (tab.isHome && !isInternal) { convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); return; @@ -528,6 +579,17 @@ function convertHomeTabToWebview(tabId, inputUrl, resolvedUrl) { if (e.channel === 'theme-update') { const home = document.getElementById('home-webview'); if (home) home.send('theme-update', ...e.args); + } else if (e.channel === 'navigate' && e.args[0]) { + const targetUrl = e.args[0]; + const opts = e.args[1] || {}; + try { + if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { + const h = new URL(targetUrl).hostname; + insecureBypassedHosts.add(h); + } + } catch {} + urlBox.value = targetUrl; + navigate(); } });