feature: Initial functional build

This commit is contained in:
Keith Solomon
2026-01-03 12:23:47 -06:00
parent cef53f2f2d
commit 046f08d559
10 changed files with 4672 additions and 2 deletions

220
src/main.js Normal file
View File

@@ -0,0 +1,220 @@
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 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 parseApacheIndex(html, baseUrl) {
const results = [];
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
const block = preMatch ? preMatch[0] : html;
const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>/gi;
let match;
while ((match = linkRegex.exec(block)) !== null) {
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;
}
results.push({ name, href: fullUrl, isDir });
}
return results;
}
function downloadFile(url, destPath) {
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;
}
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close(resolve);
});
});
req.on('error', (err) => {
fs.unlink(destPath, () => reject(err));
});
});
}
async function downloadFolderRecursive(baseUrl, destDir) {
const html = await fetchUrl(baseUrl);
const items = parseApacheIndex(html, baseUrl);
for (const item of items) {
const targetPath = path.join(destDir, item.name.replace(/\/$/, ''));
if (item.isDir) {
fs.mkdirSync(targetPath, { recursive: true });
logToRenderer(`Entering ${item.href}`);
await downloadFolderRecursive(item.href, targetPath);
} else {
logToRenderer(`Downloading ${item.href}`);
await downloadFile(item.href, targetPath);
}
}
}
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) {
if (useWget && state.hasWget) {
logToRenderer(`wget: ${url}`);
await downloadWithWget(url, destDir);
return;
}
await downloadFolderRecursive(url, destDir);
return;
}
const fileName = path.basename(new URL(url).pathname);
const destPath = path.join(destDir, fileName);
logToRenderer(`Downloading ${url}`);
await downloadFile(url, destPath);
});