jm-tech-power-official/Flash Download
A desktop download manager that opens up to 32 connections against one file and can send each of them out a different proxy route.
Public
Uncategorized
Clone
jm-tech-power-official/flash-download.jmproj
src/main/engine/manager.js
'use strict';
const fs = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
const { Download, S } = require('./download');
const { Limiter } = require('./limiter');
const { probe, classify } = require('./net');
const { dropAgents } = require('./agents');
const { SecretStore } = require('./secrets');
const FLUSH_MS = 250;
const ROUTE_FAIL_WINDOW = 60_000;
const DEFAULT_SETTINGS = {
connections: 8,
maxConcurrent: 3,
speedLimit: 0, // bytes/sec, 0 = unlimited
verifyAfter: true,
captureClipboard: true,
dropFailingRoutes: true,
routeMode: 'spread',
downloadDir: null, // filled from app paths on first run
routeTestUrl: 'https://example.com',
};
const DIRECT_ROUTE = { id: 'direct', label: 'direct', type: 'direct', enabled: true, health: 'up', latencyMs: null, lastError: null, carried: 0 };
class Manager extends EventEmitter {
constructor({ stateDir, defaultDownloadDir, secrets }) {
super();
this.stateDir = stateDir;
this.statePath = path.join(stateDir, 'state.json');
this.settings = { ...DEFAULT_SETTINGS, downloadDir: defaultDownloadDir };
// Proxy credentials live here, OS-encrypted, never in state.json.
this.secrets = secrets || new SecretStore({ filePath: path.join(stateDir, 'credentials.enc'), codec: null });
this.warnedNoSecretStore = false;
this.routes = [{ ...DIRECT_ROUTE }];
this.downloads = [];
this.limiter = new Limiter();
this.dirty = false;
this.routeFails = new Map(); // routeId -> timestamps
this.flusher = setInterval(() => {
if (!this.dirty) return;
this.dirty = false;
this.emit('change', this.snapshot());
}, FLUSH_MS);
if (this.flusher.unref) this.flusher.unref();
}
ctx() {
return {
routes: () => this.routes,
limiter: this.limiter,
onChange: () => { this.dirty = true; },
onRouteError: (routeId, err) => this.noteRouteFailure(routeId, err),
};
}
// ---- persistence --------------------------------------------------------
async load() {
let saved;
try {
saved = JSON.parse(await fs.readFile(this.statePath, 'utf8'));
} catch {
return this.save(); // first run
}
this.settings = { ...this.settings, ...(saved.settings || {}) };
if (Array.isArray(saved.routes) && saved.routes.length) {
this.routes = saved.routes;
if (!this.routes.some((r) => r.type === 'direct')) this.routes.unshift({ ...DIRECT_ROUTE });
}
this.limiter.setLimit(this.settings.speedLimit);
// A state.json from before this version may carry plaintext credentials;
// detect that so we can migrate and rewrite it clean.
const hadPlaintext = this.routes.some((r) => r.type !== 'direct' && (r.username || r.password));
// Overlay OS-encrypted credentials (the new source) onto the route records.
await this.secrets.loadInto(this.routes);
if (hadPlaintext || this.routes.some((r) => r.username || r.password)) {
if (this.secrets.available()) {
// Persist to the encrypted store and rewrite state.json without secrets.
await this.secrets.saveFrom(this.routes);
await this.save();
} else {
// No OS keyring: keep credentials in memory for this session, but strip
// them from disk now rather than leave plaintext lying in state.json.
await this.save();
this.noteNoSecretStore();
}
}
for (const rec of saved.downloads || []) {
const dl = new Download({
id: rec.id,
url: rec.url,
dir: rec.dir,
name: rec.name,
connections: rec.connections,
routeMode: rec.routeMode,
verifyAfter: rec.verifyAfter,
addedAt: rec.addedAt,
}, this.ctx());
dl.filePath = rec.filePath || null;
dl.size = rec.size ?? null;
dl.received = rec.received || 0;
dl.finishedAt = rec.finishedAt || null;
dl.verify = rec.verify || dl.verify;
// Anything mid-flight when the app closed comes back paused, not running.
dl.state = rec.state === S.DONE ? S.DONE
: rec.state === S.FAILED ? S.FAILED
: rec.state === S.QUEUED ? S.QUEUED : S.PAUSED;
this.track(dl);
}
this.dirty = true;
this.pump(); // anything still queued from last session starts now
}
/** Route fields that are safe to write to state.json — never the credentials. */
publicRoute(route) {
const { username, password, ...rest } = route; // eslint-disable-line no-unused-vars
return rest;
}
async save() {
const body = {
v: 1,
settings: this.settings,
routes: this.routes.map((r) => this.publicRoute(r)),
downloads: this.downloads.map((d) => ({
id: d.id, url: d.originUrl, name: d.name, dir: d.dir, filePath: d.filePath,
connections: d.wantConnections, routeMode: d.routeMode, verifyAfter: d.verifyAfter,
state: d.state, size: d.size, received: d.received,
addedAt: d.addedAt, finishedAt: d.finishedAt, verify: d.verify,
})),
};
await fs.mkdir(this.stateDir, { recursive: true });
await fs.writeFile(this.statePath, JSON.stringify(body, null, 2)).catch(() => {});
}
/** Warn once, via the UI, when credentials cannot be stored securely. */
noteNoSecretStore() {
if (this.warnedNoSecretStore) return;
this.warnedNoSecretStore = true;
this.emit('notice', {
kind: 'bad',
title: 'Proxy passwords will not be saved',
body: 'No system keyring is available to encrypt them, so they are kept only until you quit. They are never written to disk as plain text.',
});
}
// ---- downloads ----------------------------------------------------------
find(id) { return this.downloads.find((d) => d.id === id); }
/** Every download, restored or new, reports completion so the queue advances. */
track(dl, { front = false } = {}) {
dl.on('done', () => { this.save(); this.pump(); });
front ? this.downloads.unshift(dl) : this.downloads.push(dl);
return dl;
}
async add(opts) {
const dl = new Download({
url: opts.url,
dir: opts.dir || this.settings.downloadDir,
name: opts.name || null,
connections: opts.connections || this.settings.connections,
routeMode: opts.routeMode || this.settings.routeMode,
verifyAfter: opts.verifyAfter ?? this.settings.verifyAfter,
checksum: opts.checksum || null,
}, this.ctx());
this.track(dl, { front: true });
this.dirty = true;
if (opts.startNow === false) dl.state = S.QUEUED;
this.pump();
await this.save();
return dl.id;
}
/** Keep at most `maxConcurrent` downloads running; start queued ones in order. */
pump() {
const running = this.downloads.filter((d) => d.state === S.ACTIVE || d.state === S.PROBING || d.state === S.VERIFYING);
let free = Math.max(0, this.settings.maxConcurrent - running.length);
if (free <= 0) return;
for (const dl of [...this.downloads].reverse()) {
if (free <= 0) break;
if (dl.state === S.QUEUED) { dl.start(); free -= 1; }
}
}
async pause(id) { const d = this.find(id); if (d) { await d.pause(); this.save(); this.pump(); } }
async resume(id) {
const d = this.find(id);
if (!d) return;
if (!d.lanes.length) await d.start(); else await d.resume();
this.save();
}
async retry(id) {
const d = this.find(id);
if (!d) return;
d.error = null;
d.retries = 0;
await d.resume().catch(() => d.start());
this.save();
}
rebalance(id) { const d = this.find(id); if (d) d.rebalance(); }
async cancel(id, { deleteFile = true } = {}) {
const d = this.find(id);
if (d) { await d.cancel({ deleteFile }); this.save(); this.pump(); }
}
async remove(id, { deleteFile = false } = {}) {
const d = this.find(id);
if (!d) return;
if (d.state === S.ACTIVE || d.state === S.PROBING) await d.cancel({ deleteFile });
else if (deleteFile && d.filePath) {
await fs.rm(d.filePath, { force: true }).catch(() => {});
await d.clearSidecar();
}
this.downloads = this.downloads.filter((x) => x.id !== id);
this.dirty = true;
await this.save();
this.pump();
}
async clearFinished() {
this.downloads = this.downloads.filter((d) => d.state !== S.DONE && d.state !== S.CANCELLED);
this.dirty = true;
await this.save();
}
// ---- routes -------------------------------------------------------------
async addRoute(route) {
const rec = {
id: route.id || crypto.randomUUID().slice(0, 8),
label: route.label || `${route.host}:${route.port}`,
type: route.type || 'http',
host: route.host,
port: Number(route.port),
username: route.username || null,
password: route.password || null,
enabled: true,
health: 'up',
latencyMs: null,
lastError: null,
lastErrorAt: null,
carried: 0,
rev: 0,
};
this.routes.push(rec);
this.dirty = true;
await this.persistSecrets(rec);
await this.save();
this.testRoute(rec.id);
return rec.id;
}
async updateRoute(id, patch) {
const route = this.routes.find((r) => r.id === id);
if (!route) return;
const touchedCreds = 'username' in patch || 'password' in patch;
Object.assign(route, patch, { rev: (route.rev || 0) + 1 });
if (patch.enabled === false) route.health = 'off';
else if (patch.enabled === true && route.health === 'off') route.health = 'up';
dropAgents(id);
this.dirty = true;
if (touchedCreds) await this.persistSecrets(route);
await this.save();
}
async removeRoute(id) {
if (id === 'direct') return;
dropAgents(id);
this.routes = this.routes.filter((r) => r.id !== id);
this.dirty = true;
await this.secrets.saveFrom(this.routes); // rebuild drops the removed secret
await this.save();
}
/** Write the encrypted store after a credentialed route changes, or warn. */
async persistSecrets(route) {
if (route.type !== 'direct' && (route.username || route.password) && !this.secrets.available()) {
this.noteNoSecretStore();
}
await this.secrets.saveFrom(this.routes);
}
/** Time a real request through the route and record latency or the failure. */
async testRoute(id) {
const route = this.routes.find((r) => r.id === id);
if (!route) return;
const started = Date.now();
try {
await probe(this.settings.routeTestUrl, { route: route.type === 'direct' ? null : route });
route.latencyMs = Date.now() - started;
route.health = route.enabled === false ? 'off' : 'up';
route.lastError = null;
this.routeFails.delete(id);
} catch (err) {
const e = classify(err);
route.latencyMs = null;
route.lastError = e.message;
route.lastErrorAt = Date.now();
if (route.enabled !== false) route.health = 'degraded';
}
this.dirty = true;
await this.save();
}
async testAllRoutes() {
await Promise.all(this.routes.map((r) => this.testRoute(r.id)));
}
/** Three route-level faults inside a minute marks it degraded (and drops it). */
noteRouteFailure(routeId, err) {
if (!routeId) return;
const route = this.routes.find((r) => r.id === routeId);
if (!route) return;
route.lastError = err.message;
route.lastErrorAt = Date.now();
const now = Date.now();
const hits = (this.routeFails.get(routeId) || []).filter((t) => now - t < ROUTE_FAIL_WINDOW);
hits.push(now);
this.routeFails.set(routeId, hits);
if (hits.length >= 3 && this.settings.dropFailingRoutes && route.type !== 'direct') {
route.health = 'degraded';
this.emit('notice', {
kind: 'bad',
title: `${route.label} dropped from the pool`,
body: `Three lanes failed on it in a minute. Its work moved to the other routes and the download kept going.`,
});
}
this.dirty = true;
}
// ---- settings -----------------------------------------------------------
async updateSettings(patch) {
this.settings = { ...this.settings, ...patch };
if ('speedLimit' in patch) this.limiter.setLimit(this.settings.speedLimit);
this.dirty = true;
await this.save();
this.pump();
}
// ---- view model ---------------------------------------------------------
snapshot() {
const downloads = this.downloads.map((d) => d.snapshot());
const active = downloads.filter((d) => d.state === S.ACTIVE);
const openLanes = active.reduce((n, d) => n + d.lanes.filter((l) => l.state === 'active' || l.state === 'stalled' || l.state === 'retrying').length, 0);
const lanesByRoute = new Map();
for (const d of active) {
for (const lane of d.lanes) {
if (lane.state === 'done' || !lane.routeId) continue;
lanesByRoute.set(lane.routeId, (lanesByRoute.get(lane.routeId) || 0) + 1);
}
}
return {
settings: this.settings,
routes: this.routes.map((r) => ({
id: r.id, label: r.label, type: r.type, host: r.host || null, port: r.port || null,
enabled: r.enabled !== false, health: r.health, latencyMs: r.latencyMs,
lastError: r.lastError, lastErrorAt: r.lastErrorAt, carried: r.carried || 0,
lanes: lanesByRoute.get(r.id) || 0,
hasAuth: !!r.username,
})),
downloads,
totals: {
rate: active.reduce((n, d) => n + d.rate, 0),
openLanes,
maxLanes: this.downloads.reduce((n, d) => n + (d.state === S.ACTIVE ? d.lanes.length : 0), 0),
counts: {
active: downloads.filter((d) => d.state === S.ACTIVE || d.state === S.PROBING || d.state === S.VERIFYING).length,
queued: downloads.filter((d) => d.state === S.QUEUED).length,
paused: downloads.filter((d) => d.state === S.PAUSED).length,
done: downloads.filter((d) => d.state === S.DONE).length,
failed: downloads.filter((d) => d.state === S.FAILED).length,
},
routes: {
up: this.routes.filter((r) => r.health === 'up' && r.enabled !== false).length,
degraded: this.routes.filter((r) => r.health === 'degraded').length,
off: this.routes.filter((r) => r.enabled === false).length,
},
speedLimit: this.settings.speedLimit,
},
};
}
async shutdown() {
clearInterval(this.flusher);
this.limiter.stop();
for (const d of this.downloads) {
if (d.state === S.ACTIVE || d.state === S.PROBING) await d.pause();
}
await this.save();
}
}
module.exports = { Manager, S, DEFAULT_SETTINGS };