355 lines
10 KiB
JavaScript
355 lines
10 KiB
JavaScript
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(/<pre>[\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 href="([^"]+)">([^<]+)<\/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' });
|
|
});
|