const { app, BrowserWindow, dialog, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const http = require('http'); const https = require('https'); const { execFile } = require('child_process'); const state = { hasWget: false, }; function detectWget() { return new Promise((resolve) => { const cmd = process.platform === 'win32' ? 'where' : 'which'; execFile(cmd, ['wget'], (err, stdout) => { if (err) { resolve(false); return; } resolve(Boolean(stdout && stdout.trim())); }); }); } function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }); win.loadFile(path.join(__dirname, 'index.html')); } function logToRenderer(message) { const windows = BrowserWindow.getAllWindows(); if (!windows.length) return; windows[0].webContents.send('log', message); } function progressToRenderer(payload) { const windows = BrowserWindow.getAllWindows(); if (!windows.length) return; windows[0].webContents.send('progress', payload); } function listLocalDir(dirPath) { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); return entries.map((entry) => { const fullPath = path.join(dirPath, entry.name); let size = 0; if (!entry.isDirectory()) { try { size = fs.statSync(fullPath).size; } catch (err) { size = 0; } } return { name: entry.name, path: fullPath, isDir: entry.isDirectory(), size, }; }); } function fetchUrl(url) { const client = url.startsWith('https') ? https : http; return new Promise((resolve, reject) => { const req = client.get(url, (res) => { if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); res.resume(); return; } let data = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => resolve(data)); }); req.on('error', reject); }); } function parseSizeToken(token) { if (!token || token === '-') return null; const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i); if (!match) return null; const value = Number(match[1]); if (Number.isNaN(value)) return null; const unit = match[2] ? match[2].toUpperCase() : ''; const multipliers = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4, P: 1024 ** 5, }; const multiplier = multipliers[unit]; if (!multiplier) return null; return Math.round(value * multiplier); } function parseApacheIndex(html, baseUrl) { const results = []; const preMatch = html.match(/
[\s\S]*?<\/pre>/i);
const block = preMatch ? preMatch[0] : html;
const preLines = preMatch ? preMatch[0].split(/\r?\n/) : block.split(/\r?\n/);
const linkRegex = /([^<]+)<\/a>/i;
for (const line of preLines) {
const match = line.match(linkRegex);
if (!match) continue;
const href = match[1];
const anchorText = match[2];
if (href === '../' || anchorText === '../') continue;
const isDir = href.endsWith('/');
const fullUrl = new URL(href, baseUrl).toString();
let name = anchorText;
try {
const urlObj = new URL(href, baseUrl);
let pathname = decodeURIComponent(urlObj.pathname);
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
const derived = pathname.split('/').pop();
if (derived) {
name = isDir ? `${derived}/` : derived;
}
} catch (err) {
name = anchorText;
}
let size = '-';
let sizeBytes = null;
const rest = line.slice(match.index + match[0].length).trim();
if (rest) {
const tokens = rest.split(/\s+/);
const sizeToken = tokens[tokens.length - 1];
if (sizeToken && sizeToken !== '-') {
size = sizeToken;
sizeBytes = parseSizeToken(sizeToken);
}
}
results.push({ name, href: fullUrl, isDir, size, sizeBytes });
}
return results;
}
async function collectRemoteFiles(baseUrl, relDir) {
const html = await fetchUrl(baseUrl);
const items = parseApacheIndex(html, baseUrl);
const files = [];
for (const item of items) {
const cleanName = item.name.replace(/\/$/, '');
const nextRel = relDir ? path.join(relDir, cleanName) : cleanName;
if (item.isDir) {
const childFiles = await collectRemoteFiles(item.href, nextRel);
files.push(...childFiles);
} else {
files.push({
url: item.href,
relPath: nextRel,
sizeBytes: item.sizeBytes ?? null,
});
}
}
return files;
}
function downloadFile(url, destPath, onProgress) {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(destPath);
const req = client.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
res.resume();
return;
}
const totalBytes = Number(res.headers['content-length']) || null;
let downloaded = 0;
let lastEmit = Date.now();
onProgress?.({ phase: 'start', totalBytes });
res.pipe(fileStream);
res.on('data', (chunk) => {
downloaded += chunk.length;
const now = Date.now();
if (onProgress && (now - lastEmit > 200 || downloaded === totalBytes)) {
lastEmit = now;
onProgress({
phase: 'progress',
downloadedBytes: downloaded,
totalBytes,
});
}
});
fileStream.on('finish', () => {
fileStream.close(resolve);
});
});
req.on('error', (err) => {
fs.unlink(destPath, () => reject(err));
});
});
}
function getRemoteBasename(url) {
try {
const urlObj = new URL(url);
let pathname = decodeURIComponent(urlObj.pathname);
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
const name = pathname.split('/').pop();
return name || 'download';
} catch (err) {
return 'download';
}
}
async function downloadFolderWithPlan(baseUrl, destDir) {
progressToRenderer({ phase: 'scan-start' });
const files = await collectRemoteFiles(baseUrl, '');
progressToRenderer({
phase: 'scan-done',
fileCount: files.length,
});
const totalBytes = files.every((file) => typeof file.sizeBytes === 'number')
? files.reduce((sum, file) => sum + file.sizeBytes, 0)
: null;
progressToRenderer({
phase: 'aggregate-start',
totalBytes,
fileCount: files.length,
});
let completedBytes = 0;
for (const file of files) {
const targetPath = path.join(destDir, file.relPath);
const targetDir = path.dirname(targetPath);
fs.mkdirSync(targetDir, { recursive: true });
logToRenderer(`Downloading ${file.url}`);
progressToRenderer({ phase: 'file-start', fileName: file.relPath });
await downloadFile(file.url, targetPath, (progress) => {
const current = progress.downloadedBytes || 0;
const aggregate = completedBytes + current;
progressToRenderer({
phase: 'file-progress',
fileName: file.relPath,
downloadedBytes: current,
totalBytes: progress.totalBytes,
});
progressToRenderer({
phase: 'aggregate-progress',
downloadedBytes: aggregate,
totalBytes,
});
});
completedBytes += file.sizeBytes ?? 0;
progressToRenderer({ phase: 'file-done', fileName: file.relPath });
}
}
async function downloadWithWget(url, destDir) {
return new Promise((resolve, reject) => {
const args = ['-r', '-np', '-nH', '--cut-dirs=0', '-P', destDir, url];
const proc = execFile('wget', args, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
proc.stdout.on('data', (data) => logToRenderer(data.toString()));
proc.stderr.on('data', (data) => logToRenderer(data.toString()));
});
}
app.whenReady().then(async () => {
state.hasWget = await detectWget();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
ipcMain.handle('get-capabilities', () => {
return { hasWget: state.hasWget };
});
ipcMain.handle('select-local-dir', async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.handle('list-local-dir', (_event, dirPath) => {
return listLocalDir(dirPath);
});
ipcMain.handle('list-remote-dir', async (_event, url) => {
const html = await fetchUrl(url);
return parseApacheIndex(html, url);
});
ipcMain.handle('download-item', async (_event, payload) => {
const { url, destDir, isDir, useWget } = payload;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (isDir) {
const folderName = getRemoteBasename(url);
const targetDir = path.join(destDir, folderName);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
if (useWget && state.hasWget) {
logToRenderer(`wget: ${url}`);
progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
await downloadWithWget(url, targetDir);
progressToRenderer({ phase: 'done' });
return;
}
await downloadFolderWithPlan(url, targetDir);
progressToRenderer({ phase: 'done' });
return;
}
const fileName = path.basename(new URL(url).pathname);
const destPath = path.join(destDir, fileName);
logToRenderer(`Downloading ${url}`);
progressToRenderer({ phase: 'start', fileName });
await downloadFile(url, destPath, (progress) => {
progressToRenderer({
phase: 'progress',
fileName,
downloadedBytes: progress.downloadedBytes,
totalBytes: progress.totalBytes,
});
});
progressToRenderer({ phase: 'done' });
});