This commit is contained in:
2025-10-09 13:16:58 +13:00
7 changed files with 400 additions and 24 deletions
+143 -6
View File
@@ -3,6 +3,7 @@ const { pathToFileURL } = require('url');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { spawn } = require('child_process');
const PerformanceMonitor = require('./performance-monitor'); const PerformanceMonitor = require('./performance-monitor');
const GPUFallback = require('./gpu-fallback'); const GPUFallback = require('./gpu-fallback');
const GPUConfig = require('./gpu-config'); const GPUConfig = require('./gpu-config');
@@ -909,7 +910,7 @@ ipcMain.handle('save-image-from-url', async (event, { url }) => {
// ========================= // =========================
// In-memory download registry // 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) { function broadcastToAll(channel, payload) {
try { try {
@@ -951,7 +952,8 @@ function registerDownloadHandling(ses) {
startedAt: Date.now(), startedAt: Date.now(),
mime, mime,
canResume: false, 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 }); downloads.set(id, { ...info, item });
const payload = { ...info }; 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 d = downloads.get(id) || {};
const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted'); const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted');
const final = { const final = {
@@ -988,11 +990,34 @@ function registerDownloadHandling(ses) {
state: finalState, state: finalState,
startedAt: d.startedAt || Date.now(), startedAt: d.startedAt || Date.now(),
endedAt: 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 // Store minimal object; drop live item ref
downloads.set(id, final); downloads.set(id, final);
broadcastToAll('downloads-done', 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) { } catch (err) {
console.error('will-download handler error:', err); console.error('will-download handler error:', err);
@@ -1039,7 +1064,8 @@ ipcMain.handle('downloads-get-all', () => {
totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0, totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0,
state: rest.state || 'in-progress', state: rest.state || 'in-progress',
paused: item.isPaused?.() || false, 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; return rest;
@@ -1062,6 +1088,46 @@ ipcMain.handle('downloads-action', async (event, { id, action }) => {
case 'cancel': case 'cancel':
if (item && d.state === 'in-progress') item.cancel?.(); if (item && d.state === 'in-progress') item.cancel?.();
return true; 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': case 'open-file':
if (d.savePath) { if (d.savePath) {
await shell.openPath(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) // IPC: clear completed entries from the registry (keeps in-progress)
ipcMain.handle('downloads-clear-completed', () => { ipcMain.handle('downloads-clear-completed', () => {
for (const [id, d] of downloads.entries()) { 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'); broadcastToAll('downloads-cleared');
return true; 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 });
}
});
});
}
+40 -4
View File
@@ -196,9 +196,19 @@
if(!chat){ return; } if(!chat){ return; }
chat.messages.forEach(m=>{ chat.messages.forEach(m=>{
const div = h('div',{class:'msg '+m.role}); const div = h('div',{class:'msg '+m.role});
div.innerHTML = '<div class="markdown">'+renderMarkdown(m.content)+'</div>'; 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 // Enhance links for security (in case already rendered)
div.querySelectorAll('a[href]').forEach(a => { div.querySelectorAll('a[href]').forEach(a => {
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer'); a.setAttribute('rel', 'noopener noreferrer');
@@ -308,6 +318,8 @@
typeNext(); typeNext();
} }
// Keep a registry of handlers so we can remove previous listeners reliably
const streamHandlers = new Map();
function subscribeStream(id){ function subscribeStream(id){
const channel = 'ollama-chat:stream:' + id; const channel = 'ollama-chat:stream:' + id;
console.log('[Nebot Page] Subscribing to stream channel:', channel); console.log('[Nebot Page] Subscribing to stream channel:', channel);
@@ -316,9 +328,12 @@
typingQueue = []; typingQueue = [];
isTyping = false; isTyping = false;
// Remove any existing listeners for this channel // Remove any existing listener registered earlier for this channel
if (window.electronAPI && window.electronAPI.removeListener) { 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) { function handleStreamPayload(...args) {
@@ -398,6 +413,7 @@
if (window.electronAPI && window.electronAPI.on) { if (window.electronAPI && window.electronAPI.on) {
console.log('[Nebot Page] Setting up stream listener via electronAPI'); console.log('[Nebot Page] Setting up stream listener via electronAPI');
window.electronAPI.on(channel, handleStreamPayload); window.electronAPI.on(channel, handleStreamPayload);
streamHandlers.set(channel, handleStreamPayload);
} else { } else {
console.warn('[Nebot Page] electronAPI.on not available for stream subscription'); console.warn('[Nebot Page] electronAPI.on not available for stream subscription');
} }
@@ -583,4 +599,24 @@
initializeSettings().then(() => { initializeSettings().then(() => {
refreshList().then(()=>{ if(state.chats[0]) openChat(state.chats[0].id); }); 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); }
})(); })();
+27 -1
View File
@@ -7,7 +7,10 @@ try {
marked = require('marked'); marked = require('marked');
hljs = require('highlight.js'); hljs = require('highlight.js');
createDOMPurify = require('dompurify'); 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({ marked.setOptions({
breaks: true, breaks: true,
highlight(code, lang) { highlight(code, lang) {
@@ -24,15 +27,38 @@ try {
// Expose to page context so page.html no longer needs CDN scripts // Expose to page context so page.html no longer needs CDN scripts
try { try {
if (typeof window !== 'undefined') { 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.marked = marked;
window.DOMPurify = DOMPurify; window.DOMPurify = DOMPurify;
window.hljs = hljs; window.hljs = hljs;
} }
} catch {} } 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) { } catch (e) {
// If libs aren't available yet, we'll gracefully render as plain text. // 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'; const pluginId = 'ollama-chat';
// Expose minimal API for page scripts (optional) // Expose minimal API for page scripts (optional)
+3 -1
View File
@@ -129,7 +129,9 @@ contextBridge.exposeInMainWorld('downloadsAPI', {
onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)), onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)),
onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)), onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)),
onDone: (handler) => ipcRenderer.on('downloads-done', (_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))
}); });
// ---------------------------------------- // ----------------------------------------
+31 -2
View File
@@ -21,6 +21,10 @@
.row { display: flex; gap: 12px; justify-content: space-between; align-items: center; } .row { display: flex; gap: 12px; justify-content: space-between; align-items: center; }
.empty { color: #888; font-style: italic; padding: 20px; text-align: 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; } .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; }
</style> </style>
</head> </head>
<body> <body>
@@ -46,8 +50,28 @@
return (n/Math.pow(1024,i)).toFixed( i===0 ? 0 : 1 ) + ' ' + u[i]; return (n/Math.pow(1024,i)).toFixed( i===0 ? 0 : 1 ) + ' ' + u[i];
} }
function esc(s) {
return (s || '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[c]);
}
function rowHtml(d){ function rowHtml(d){
const pct = d.totalBytes > 0 ? Math.min(100, Math.round((d.receivedBytes||0) * 100 / d.totalBytes)) : 0; 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 ` return `
<div class="download-item" id="dl-${d.id}"> <div class="download-item" id="dl-${d.id}">
<div class="file" title="${d.filename}">${d.filename}</div> <div class="file" title="${d.filename}">${d.filename}</div>
@@ -56,13 +80,16 @@
<button data-act="${d.paused?'resume':'pause'}" data-id="${d.id}">${d.paused?'Resume':'Pause'}</button> <button data-act="${d.paused?'resume':'pause'}" data-id="${d.id}">${d.paused?'Resume':'Pause'}</button>
<button data-act="cancel" data-id="${d.id}">Cancel</button> <button data-act="cancel" data-id="${d.id}">Cancel</button>
` : ` ` : `
<button data-act="open-file" data-id="${d.id}" ${d.state!=='completed'?'disabled':''}>Open</button> <button data-act="open-file" data-id="${d.id}" ${(d.state!=='completed'||isInfected)?'disabled':''}>Open</button>
<button data-act="show-in-folder" data-id="${d.id}">Show in Folder</button> <button data-act="show-in-folder" data-id="${d.id}">Show in Folder</button>
${isInfected ? `<button data-act="delete-file" data-id="${d.id}">Delete</button>` : ''}
${d.state!=='in-progress' ? `<button data-act="rescan" data-id="${d.id}" ${isScanning?'disabled':''}>Rescan</button>` : ''}
`} `}
</div> </div>
<div class="meta"> <div class="meta">
<span class="state">${d.state}</span> <span class="state">${d.state}</span>
· ${fmtBytes(d.receivedBytes||0)} / ${fmtBytes(d.totalBytes||0)} · ${fmtBytes(d.receivedBytes||0)} / ${fmtBytes(d.totalBytes||0)}
· <span class="${scanCls}">${scanText}</span>
</div> </div>
<div class="progress"><div class="bar" style="width:${pct}%"></div></div> <div class="progress"><div class="bar" style="width:${pct}%"></div></div>
</div> </div>
@@ -102,7 +129,9 @@
// For simplicity now, refresh list // For simplicity now, refresh list
refresh(); refresh();
}); });
api.onDone(()=> refresh()); api.onDone(()=> refresh());
api.onScanStarted(()=> refresh());
api.onScanResult(()=> refresh());
api.onCleared(()=> refresh()); api.onCleared(()=> refresh());
refresh(); refresh();
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Connection Not Secure</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root {
--bg:#121212; --panel:#1e1e1e; --warn:#d97706; --danger:#dc2626; --text:#f5f5f5; --muted:#9ca3af; --accent:#6366f1;
color-scheme: dark;
}
body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Inter,Ubuntu,sans-serif; background:var(--bg); color:var(--text); display:flex; min-height:100vh; align-items:center; justify-content:center; padding:32px; }
.card { max-width:780px; width:100%; background:linear-gradient(145deg,#1c1c1c,#242424); border:1px solid #2c2c2c; border-radius:20px; padding:40px 46px 48px; box-shadow:0 8px 28px -6px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,0.04); position:relative; overflow:hidden; }
.card:before { content:""; position:absolute; inset:0; background:radial-gradient(circle at 18% 15%,rgba(255,255,255,.08),transparent 55%), radial-gradient(circle at 82% 78%,rgba(255,255,255,.05),transparent 60%); pointer-events:none; }
h1 { font-size: clamp(1.9rem, 2.6vw, 2.6rem); margin:0 0 12px; letter-spacing:-.5px; display:flex; align-items:center; gap:.6rem; }
h1 span.badge { font-size:12px; letter-spacing:1px; padding:4px 8px; border:1px solid var(--warn); color:var(--warn); border-radius:999px; text-transform:uppercase; background:rgba(217,119,6,0.1); }
p.lede { font-size:1.05rem; line-height:1.55; margin:0 0 22px; color:var(--muted); }
code { background:#252525; padding:3px 6px; border-radius:6px; font-size:.9rem; color:#e0e0e0; }
.url-box { font-family:monospace; font-size:.92rem; padding:10px 12px; background:#181818; border:1px solid #2a2a2a; border-radius:10px; word-break:break-all; margin:0 0 22px; display:flex; align-items:center; gap:.75rem; }
.url-box svg { flex:0 0 auto; width:22px; height:22px; stroke:var(--warn); }
ul { margin:0 0 26px 1.1rem; padding:0; line-height:1.5; color:var(--muted); }
ul li { margin-bottom:6px; }
.actions { display:flex; flex-wrap:wrap; gap:14px; }
button { cursor:pointer; font-size:.95rem; letter-spacing:.4px; font-weight:500; border-radius:12px; padding:14px 26px; border:1px solid transparent; background:linear-gradient(135deg,#303030,#252525); color:#fff; position:relative; overflow:hidden; transition:.25s; }
button.primary { background:linear-gradient(135deg,#6366f1,#5145cd); box-shadow:0 4px 18px -4px rgba(99,102,241,.5); }
button.danger { background:linear-gradient(135deg,#b91c1c,#7f1d1d); border-color:#dc2626; }
button.outline { background:transparent; border-color:#444; }
button:hover { filter:brightness(1.12); transform:translateY(-2px); }
button:active { transform:translateY(0); filter:brightness(.9); }
.mini { font-size:.75rem; text-transform:uppercase; letter-spacing:1px; opacity:.8; margin-top:24px; }
.fade-in { animation:fade .5s ease .05s both; }
@keyframes fade { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform:none; } }
.grid { display:grid; gap:40px; }
@media (max-width:760px){ .card{padding:34px 28px 40px;} h1{font-size:2rem;} }
</style>
</head>
<body>
<div class="card fade-in">
<h1>
<svg viewBox="0 0 24 24" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M12 2 2 7l10 5 10-5-10-5Z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Connection Not Secure <span class="badge">http</span>
</h1>
<p class="lede">Youre about to visit a page using <strong>HTTP (unencrypted)</strong>. 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.</p>
<div class="url-box" id="targetBox" title="Target URL"></div>
<ul>
<li>No TLS encryption data (including passwords or forms) travels in plain text.</li>
<li>Attackers on the same network (café WiFi, school, workplace) could tamper with or read content.</li>
<li>The site might support HTTPS. Try manually changing to <code>https://</code> first.</li>
<li>Proceed only if necessary and you have a reason to trust this destination.</li>
</ul>
<div class="actions">
<button id="backBtn" class="outline" aria-label="Go Back">Go Back</button>
<button id="tryHttps" class="primary" aria-label="Retry with HTTPS">Try HTTPS</button>
<button id="continueBtn" class="danger" aria-label="Continue (HTTP)">Continue Anyway</button>
</div>
<div class="mini">Nebula Secure Navigation Interstitial</div>
</div>
<script>
(function(){
const params = new URLSearchParams(location.search);
const target = params.get('target');
const box = document.getElementById('targetBox');
if (target) box.textContent = target;
function sendNavigate(url, opts){
if (window.electronAPI && window.electronAPI.sendToHost){
window.electronAPI.sendToHost('navigate', url, opts||{});
} else if (window.parent) {
window.parent.postMessage({ type:'navigate', url, opts }, '*');
}
}
document.getElementById('backBtn').onclick = () => history.length > 1 ? history.back() : sendNavigate('browser://home');
document.getElementById('tryHttps').onclick = () => {
if (!target) return; try {
const u = new URL(target.replace(/^http:/,'https:'));
sendNavigate(u.href);
} catch { sendNavigate(target.replace(/^http:/,'https:')); }
};
document.getElementById('continueBtn').onclick = () => {
if (!target) return; sendNavigate(target, { insecureBypass:true });
};
})();
</script>
</body>
</html>
+70 -8
View File
@@ -52,7 +52,9 @@ urlBox.addEventListener('keydown', (e) => {
let tabs = []; let tabs = [];
let activeTabId = null; 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 pluginPages = []; // { id, file, fileUrl, pluginId }
let pluginPagesReady = false; let pluginPagesReady = false;
const pendingInternalNavigations = []; const pendingInternalNavigations = [];
@@ -65,6 +67,16 @@ window.addEventListener('message', (e) => {
if (data.type === 'open-internal-page' && typeof data.url === 'string') { if (data.type === 'open-internal-page' && typeof data.url === 'string') {
console.log('[DEBUG] Message request to open internal page:', data.url); console.log('[DEBUG] Message request to open internal page:', data.url);
createTab(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) { } catch (err) {
console.warn('[DEBUG] open-internal-page handler error', err); console.warn('[DEBUG] open-internal-page handler error', err);
@@ -300,8 +312,15 @@ function createTab(inputUrl) {
if (e.channel === 'navigate' && e.args[0]) { if (e.channel === 'navigate' && e.args[0]) {
const targetUrl = e.args[0]; const targetUrl = e.args[0];
const opts = e.args[1] || {}; 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) { if (opts.newTab) {
createTab(targetUrl); createTab(targetUrl);
} else { } else {
urlBox.value = targetUrl; urlBox.value = targetUrl;
navigate(); navigate();
@@ -338,7 +357,10 @@ try { window.createTab = createTab; } catch {}
function resolveInternalUrl(url) { function resolveInternalUrl(url) {
console.log('[DEBUG] resolveInternalUrl called with:', url); console.log('[DEBUG] resolveInternalUrl called with:', url);
if (url.startsWith('browser://')) { 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); console.log('[DEBUG] Extracted page:', page);
// Fast path: if user typed browser://nebot and plugin page exists, return immediately // Fast path: if user typed browser://nebot and plugin page exists, return immediately
if (page === 'nebot') { if (page === 'nebot') {
@@ -358,14 +380,14 @@ function resolveInternalUrl(url) {
console.log('[DEBUG] Resolving browser://' + page, 'plug:', plug); console.log('[DEBUG] Resolving browser://' + page, 'plug:', plug);
if (plug && (plug.fileUrl || plug.file)) { if (plug && (plug.fileUrl || plug.file)) {
// Prefer pre-built fileUrl for correctness across platforms // 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,'/')); 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); console.log('[DEBUG] Resolved plugin page', page, '->', resolved);
return resolved; return resolved + suffix;
} }
// Fallback: built-in renderer copy (e.g., renderer/nebot.html) // Fallback: built-in renderer copy (e.g., renderer/nebot.html)
console.log('[DEBUG] Using fallback for page:', page); console.log('[DEBUG] Using fallback for page:', page);
if (page === 'nebot') return 'nebot.html'; if (page === 'nebot') return 'nebot.html' + suffix;
return `${page}.html`; return `${page}.html${suffix}`;
} }
console.log('[DEBUG] Page not in allowedInternalPages, returning 404'); console.log('[DEBUG] Page not in allowedInternalPages, returning 404');
return '404.html'; 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); 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) { if (tab.isHome && !isInternal) {
convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); convertHomeTabToWebview(tab.id, originalInputForHistory, resolved);
return; return;
@@ -528,6 +579,17 @@ function convertHomeTabToWebview(tabId, inputUrl, resolvedUrl) {
if (e.channel === 'theme-update') { if (e.channel === 'theme-update') {
const home = document.getElementById('home-webview'); const home = document.getElementById('home-webview');
if (home) home.send('theme-update', ...e.args); 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();
} }
}); });