From 37c1f982611c9a521c5cfb550e8f88cf7a88d153 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Mon, 8 Sep 2025 12:31:01 +1200 Subject: [PATCH] Add integrated downloads manager and UI Implements a full downloads manager with Electron main process handling, IPC APIs, and renderer integration. Adds a dedicated downloads page, a mini downloads popup in the navigation bar with progress ring, and controls for pausing, resuming, canceling, opening, and showing downloads. Updates styles and navigation to support the new downloads features. --- main.js | 204 +++++++++++++++++++++++++++++++++++++++- preload.js | 11 +++ renderer/downloads.html | 111 ++++++++++++++++++++++ renderer/index.html | 18 +++- renderer/script.js | 137 ++++++++++++++++++++++++++- renderer/style.css | 86 +++++++++++++++++ site-history.json | 9 ++ 7 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 renderer/downloads.html diff --git a/main.js b/main.js index 8f62429..7c6ee0a 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard } = require('electron'); +const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron'); const { pathToFileURL } = require('url'); const fs = require('fs'); const path = require('path'); @@ -317,6 +317,16 @@ app.whenReady().then(() => { // Defer session configuration to microtask/next tick (already inexpensive) – keep explicit setImmediate(configureSessionsAsync); + // Register download handlers for common sessions + try { + const mainSes = session.fromPartition('persist:main'); + const defSes = session.defaultSession; + if (mainSes) registerDownloadHandling(mainSes); + if (defSes && defSes !== mainSes) registerDownloadHandling(defSes); + } catch (e) { + console.warn('Failed to register download handlers:', e); + } + if (process.platform === 'darwin') { app.dock.setIcon(path.join(__dirname, 'assets/images/Logos/Nebula-Icon.icns')); } @@ -669,6 +679,10 @@ function buildAndShowContextMenu(sender, params = {}) { if (linkURL) { template.push( { label: 'Open Link in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-link-new-tab', url: linkURL }) }, + { label: 'Download Link', click: () => { + try { (sender.hostWebContents || sender).downloadURL(linkURL); } catch (e) { console.error('downloadURL failed:', e); } + } + }, { label: 'Open Link Externally', click: () => shell.openExternal(linkURL).catch(()=>{}) }, { label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) }, { type: 'separator' } @@ -811,3 +825,191 @@ ipcMain.handle('save-image-from-url', async (event, { url }) => { return false; } }); + +// ========================= +// Download manager plumbing +// ========================= + +// In-memory download registry +const downloads = new Map(); // id -> { id, url, filename, savePath, totalBytes, receivedBytes, state, startedAt, mime, canResume, paused } + +function broadcastToAll(channel, payload) { + try { + for (const wc of webContents.getAllWebContents()) { + try { wc.send(channel, payload); } catch {} + } + } catch (e) { + // Fallback to windows only + for (const win of BrowserWindow.getAllWindows()) { + try { win.webContents.send(channel, payload); } catch {} + } + } +} + +function registerDownloadHandling(ses) { + if (!ses || ses.__nebulaDownloadsHooked) return; + ses.__nebulaDownloadsHooked = true; + ses.on('will-download', async (event, item, wc) => { + try { + // Build an id (prefer stable GUID if available) + const id = typeof item.getGUID === 'function' ? item.getGUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + item.__nebulaId = id; + const filename = item.getFilename(); + const mime = item.getMimeType?.() || 'application/octet-stream'; + const totalBytes = item.getTotalBytes(); + const url = item.getURL(); + + // Choose a default save path under user's Downloads, ensure unique to avoid overwrite + const defaultDir = app.getPath('downloads'); + const uniquePath = await computeUniqueSavePath(defaultDir, filename); + try { item.setSavePath(uniquePath); } catch {} + + const info = { + id, url, filename, + savePath: uniquePath, + totalBytes, + receivedBytes: 0, + state: 'in-progress', + startedAt: Date.now(), + mime, + canResume: false, + paused: false + }; + downloads.set(id, { ...info, item }); + const payload = { ...info }; + broadcastToAll('downloads-started', payload); + + item.on('updated', (e, state) => { + const d = downloads.get(id); + if (!d) return; + d.receivedBytes = item.getReceivedBytes(); + d.canResume = !!item.canResume?.(); + d.paused = !!item.isPaused?.(); + d.state = state === 'interrupted' ? 'interrupted' : 'in-progress'; + downloads.set(id, d); + broadcastToAll('downloads-updated', { + id, + receivedBytes: d.receivedBytes, + totalBytes: d.totalBytes, + state: d.state, + canResume: d.canResume, + paused: d.paused + }); + }); + + item.once('done', (e, state) => { + const d = downloads.get(id) || {}; + const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted'); + const final = { + id, + url, + filename, + savePath: item.getSavePath?.() || d.savePath, + totalBytes: d.totalBytes || item.getTotalBytes?.() || 0, + receivedBytes: item.getReceivedBytes?.() || d.receivedBytes || 0, + state: finalState, + startedAt: d.startedAt || Date.now(), + endedAt: Date.now(), + mime + }; + // Store minimal object; drop live item ref + downloads.set(id, final); + broadcastToAll('downloads-done', final); + }); + } catch (err) { + console.error('will-download handler error:', err); + } + }); +} + +async function computeUniqueSavePath(dir, baseName) { + try { + const target = path.join(dir, baseName); + try { + await fs.promises.access(target); + // Already exists, create a (n) suffix + const { name, ext } = splitNameExt(baseName); + for (let i = 1; i < 10000; i++) { + const candidate = path.join(dir, `${name} (${i})${ext}`); + try { await fs.promises.access(candidate); } catch { return candidate; } + } + // Fallback if too many + return path.join(dir, `${Date.now()}-${baseName}`); + } catch { + return target; // does not exist + } + } catch (e) { + // Fallback to temp directory + return path.join(app.getPath('downloads'), `${Date.now()}-${baseName}`); + } +} + +function splitNameExt(filename) { + const ext = path.extname(filename); + const name = filename.slice(0, filename.length - ext.length); + return { name, ext }; +} + +// IPC: list downloads +ipcMain.handle('downloads-get-all', () => { + return Array.from(downloads.values()).map(d => { + const { item, ...rest } = d; + if (item) { + return { + ...rest, + receivedBytes: item.getReceivedBytes?.() ?? rest.receivedBytes ?? 0, + totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0, + state: rest.state || 'in-progress', + paused: item.isPaused?.() || false, + canResume: item.canResume?.() || false + }; + } + return rest; + }); +}); + +// IPC: control a download (pause/resume/cancel/open/show) +ipcMain.handle('downloads-action', async (event, { id, action }) => { + const d = downloads.get(id); + if (!d) return false; + const item = d.item; + try { + switch (action) { + case 'pause': + if (item && !item.isPaused?.()) item.pause?.(); + return true; + case 'resume': + if (item && item.canResume?.()) item.resume?.(); + return true; + case 'cancel': + if (item && d.state === 'in-progress') item.cancel?.(); + return true; + case 'open-file': + if (d.savePath) { + await shell.openPath(d.savePath); + return true; + } + return false; + case 'show-in-folder': + if (d.savePath) { + shell.showItemInFolder(d.savePath); + return true; + } + return false; + default: + return false; + } + } catch (e) { + console.error('downloads-action error:', e); + return false; + } +}); + +// 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); + } + broadcastToAll('downloads-cleared'); + return true; +}); diff --git a/preload.js b/preload.js index fafa572..6630c05 100644 --- a/preload.js +++ b/preload.js @@ -119,4 +119,15 @@ contextBridge.exposeInMainWorld('aboutAPI', { // Relay context-menu commands from main to active renderer context (open new tabs etc.) ipcRenderer.on('context-menu-command', (event, payload) => { window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload })); +}); + +// Downloads API exposed to renderer +contextBridge.exposeInMainWorld('downloadsAPI', { + list: () => ipcRenderer.invoke('downloads-get-all'), + action: (id, action) => ipcRenderer.invoke('downloads-action', { id, action }), + clearCompleted: () => ipcRenderer.invoke('downloads-clear-completed'), + 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) }); \ No newline at end of file diff --git a/renderer/downloads.html b/renderer/downloads.html new file mode 100644 index 0000000..1c51366 --- /dev/null +++ b/renderer/downloads.html @@ -0,0 +1,111 @@ + + + + + Downloads + + + + + +
+

Downloads

+
+ +
+
+
+ + + + + diff --git a/renderer/index.html b/renderer/index.html index 14190a4..a2ddfbd 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -40,7 +40,23 @@