Merge branch 'main' of https://github.com/NebulaZMG/NebulaBrowser
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+42
-6
@@ -196,14 +196,24 @@
|
|||||||
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
|
||||||
// Enhance links for security
|
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 => {
|
div.querySelectorAll('a[href]').forEach(a => {
|
||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
els.messages.appendChild(div);
|
els.messages.appendChild(div);
|
||||||
});
|
});
|
||||||
els.messages.scrollTop = els.messages.scrollHeight;
|
els.messages.scrollTop = els.messages.scrollHeight;
|
||||||
@@ -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); }
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
})[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();
|
||||||
|
|||||||
@@ -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">You’re 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é Wi‑Fi, 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
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user