JM Tech Power

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 };