feat: add folder support and system tray icon

- Add "+ Ordner" button for recursive folder upload
- Drag & drop auto-detects folders and resolves files recursively
- Minimize to system tray instead of taskbar
- Tray icon with context menu (Öffnen/Beenden)
- Tray tooltip shows upload progress during active uploads
- Fix folder detection heuristic (size === 0, not % 4096)
- Fix concurrent drop guard to prevent double modal
- Fix duplicate "Erneut versuchen" context menu entry
- Add .catch() on async drop handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-12 00:44:14 +01:00
parent 6b2b2ca04c
commit 0480da0437
5 changed files with 131 additions and 27 deletions

70
main.js
View File

@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog, clipboard, powerSaveBlocker, nativeTheme } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, clipboard, powerSaveBlocker, nativeTheme, Tray, Menu } = require('electron');
nativeTheme.themeSource = 'dark'; nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@ -12,6 +12,7 @@ const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
const backupCrypto = require('./lib/backup-crypto'); const backupCrypto = require('./lib/backup-crypto');
let mainWindow; let mainWindow;
let tray = null;
const configStore = new ConfigStore(app); const configStore = new ConfigStore(app);
let uploadManager = null; let uploadManager = null;
const HEALTH_CHECK_TIMEOUT = 25000; const HEALTH_CHECK_TIMEOUT = 25000;
@ -453,8 +454,35 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
} }
function createTray() {
const iconPath = path.join(__dirname, 'assets', 'app_icon.ico');
tray = new Tray(iconPath);
tray.setToolTip('Multi-Hoster-Upload');
const contextMenu = Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } },
{ type: 'separator' },
{ label: 'Beenden', click: () => { app.quit(); } }
]);
tray.setContextMenu(contextMenu);
tray.on('click', () => {
if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
});
}
function updateTrayTooltip(text) {
if (tray && !tray.isDestroyed()) tray.setToolTip(text);
}
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
createTray();
// Minimize to tray instead of taskbar
mainWindow.on('minimize', () => {
mainWindow.hide();
});
// Auto-check for updates after 3 seconds // Auto-check for updates after 3 seconds
setTimeout(async () => { setTimeout(async () => {
@ -536,9 +564,38 @@ ipcMain.handle('debug-test-upload', async () => {
ipcMain.handle('select-folder', async () => { ipcMain.handle('select-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'] properties: ['openDirectory', 'multiSelections']
}); });
return result.canceled ? null : result.filePaths; if (result.canceled || !result.filePaths.length) return null;
// Recursively collect all files from selected folders
const files = [];
const walk = (dir) => {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) files.push(full);
}
} catch {}
};
for (const folder of result.filePaths) walk(folder);
return files.length > 0 ? files : null;
});
ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
const files = [];
const walk = (dir) => {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) files.push(full);
}
} catch {}
};
walk(folderPath);
return files;
}); });
ipcMain.handle('start-upload', (_event, payload) => { ipcMain.handle('start-upload', (_event, payload) => {
@ -583,6 +640,13 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-stats', data); mainWindow.webContents.send('upload-stats', data);
} }
// Update tray tooltip with upload progress
if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
} else {
updateTrayTooltip('Multi-Hoster-Upload');
}
}); });
uploadManager.on('batch-done', async (summary) => { uploadManager.on('batch-done', async (summary) => {

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "1.8.5", "version": "1.8.6",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld('api', {
// File selection // File selection
selectFiles: () => ipcRenderer.invoke('select-files'), selectFiles: () => ipcRenderer.invoke('select-files'),
selectFolder: () => ipcRenderer.invoke('select-folder'), selectFolder: () => ipcRenderer.invoke('select-folder'),
resolveFolderFiles: (folderPath) => ipcRenderer.invoke('resolve-folder-files', folderPath),
// Upload control // Upload control
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload), startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),

View File

@ -391,7 +391,7 @@ function setupDragDrop() {
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
dropZone.addEventListener('drop', (e) => { dropZone.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over');
addDroppedFiles(e.dataTransfer.files); addDroppedFiles(e.dataTransfer.files).catch(console.error);
}); });
dropZone.addEventListener('click', () => pickFiles()); dropZone.addEventListener('click', () => pickFiles());
@ -400,38 +400,77 @@ function setupDragDrop() {
uploadView.addEventListener('drop', (e) => { uploadView.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
if (e.target.closest('.drop-zone')) return; // handled above if (e.target.closest('.drop-zone')) return; // handled above
addDroppedFiles(e.dataTransfer.files); addDroppedFiles(e.dataTransfer.files).catch(console.error);
}); });
} }
let _pendingFiles = []; // Files waiting for hoster modal confirmation let _pendingFiles = []; // Files waiting for hoster modal confirmation
function addDroppedFiles(fileList) { let _addingDropped = false;
async function addDroppedFiles(fileList) {
if (_addingDropped) return;
_addingDropped = true;
try {
const files = Array.from(fileList); const files = Array.from(fileList);
const existingPaths = new Set([ const existingPaths = new Set([
...selectedFiles.map(f => f.path), ...selectedFiles.map(f => f.path),
..._pendingFiles.map(f => f.path) ..._pendingFiles.map(f => f.path)
]); ]);
const newFiles = []; const newFiles = [];
for (const file of files) { for (const file of files) {
// Use webUtils.getPathForFile (Electron 33+) with fallback to file.path
let filePath = ''; let filePath = '';
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
if (!filePath) continue;
// Detect folders: directories report size 0 and empty type in Electron drag-and-drop
if (file.type === '' && file.size === 0) {
try {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (folderFiles && folderFiles.length > 0) {
for (const fp of folderFiles) {
if (!existingPaths.has(fp)) {
const name = fp.split('\\').pop().split('/').pop();
newFiles.push({ path: fp, name, size: null });
existingPaths.add(fp);
}
}
continue;
}
} catch {}
}
// Regular file
const fileName = file.name || ''; const fileName = file.name || '';
if (filePath && !existingPaths.has(filePath)) { if (!existingPaths.has(filePath)) {
newFiles.push({ path: filePath, name: fileName, size: file.size }); newFiles.push({ path: filePath, name: fileName, size: file.size });
existingPaths.add(filePath); existingPaths.add(filePath);
} }
} }
if (newFiles.length > 0) { if (newFiles.length > 0) {
_pendingFiles.push(...newFiles); _pendingFiles.push(...newFiles);
openHosterModal(); openHosterModal();
} }
} finally {
_addingDropped = false;
}
} }
async function pickFiles() { async function pickFiles() {
const paths = await window.api.selectFiles(); const paths = await window.api.selectFiles();
if (!paths) return; if (!paths) return;
addPathsToQueue(paths);
}
async function pickFolder() {
const paths = await window.api.selectFolder();
if (!paths) return;
addPathsToQueue(paths);
}
function addPathsToQueue(paths) {
const newFiles = []; const newFiles = [];
for (const p of paths) { for (const p of paths) {
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) {
@ -2327,6 +2366,7 @@ window.addEventListener('beforeunload', () => {
// --- Setup Listeners --- // --- Setup Listeners ---
function setupListeners() { function setupListeners() {
document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
document.getElementById('addFolderBtn').addEventListener('click', pickFolder);
document.getElementById('startUploadBtn').addEventListener('click', startUpload); document.getElementById('startUploadBtn').addEventListener('click', startUpload);
document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload);

View File

@ -28,6 +28,7 @@
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button> <button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
</div> </div>
</div> </div>
@ -272,8 +273,6 @@
<div class="ctx-separator"></div> <div class="ctx-separator"></div>
<div class="ctx-item" data-action="delete-selected">Entfernen</div> <div class="ctx-item" data-action="delete-selected">Entfernen</div>
<div class="ctx-item" data-action="delete-all">Alle entfernen</div> <div class="ctx-item" data-action="delete-all">Alle entfernen</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
</div> </div>
<div class="context-menu" id="recentContextMenu" style="display:none"> <div class="context-menu" id="recentContextMenu" style="display:none">