diff --git a/CHANGELOG.PATCHED.md b/CHANGELOG.PATCHED.md
index 227bff2..0e180ac 100644
--- a/CHANGELOG.PATCHED.md
+++ b/CHANGELOG.PATCHED.md
@@ -1,5 +1,8 @@
# CHANGELOG - PATCHED
+**v0.9.9-patched.nightly.494.5**
+ - Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. [#408](https://github.com/towerwatchman/Atlas/pull/408)
+
**v0.9.9-patched.nightly.494.4**
- Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)
diff --git a/PATCHES.md b/PATCHES.md
index 9ec90c7..0fe60c5 100644
--- a/PATCHES.md
+++ b/PATCHES.md
@@ -2,6 +2,7 @@
## Pending Patched Changes
*Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
+ - Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. [#408](https://github.com/towerwatchman/Atlas/pull/408)
- Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)
- Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404)
- Removed the stale restart popup and hint on the Show debug console toggle — it applies immediately to all open windows.[#399](https://github.com/towerwatchman/Atlas/pull/399)
diff --git a/electron/downloads/downloadManager.js b/electron/downloads/downloadManager.js
index ccaa8cd..3f9bbbf 100644
--- a/electron/downloads/downloadManager.js
+++ b/electron/downloads/downloadManager.js
@@ -344,6 +344,11 @@ const startTransfer = async (item) => {
return;
}
if (!probe.passthrough) {
+ if (probe.choices) {
+ await handleFailure(item.id, "fatal",
+ `This folder has ${probe.choices.length} files. Pick one to download.`);
+ return;
+ }
transferUrl = probe.directUrl || item.url;
decryptSpec = probe.decrypt || null;
Object.assign(headers, probe.headers || {});
diff --git a/electron/downloads/hosts/gofile.js b/electron/downloads/hosts/gofile.js
new file mode 100644
index 0000000..2ecb8da
--- /dev/null
+++ b/electron/downloads/hosts/gofile.js
@@ -0,0 +1,570 @@
+"use strict";
+
+// Gofile host plugin, free route. Premium API for direct links will be
+// another approach when needed.
+
+const { createHash } = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+// Free allowance in decimal bytes, matching gofile.io display.
+// This is not mentioned in documents, but it's the current limit for Guest account
+const GUEST_TRAFFIC_CAP = 1000000000000;
+const TRAFFIC_WINDOW_DAYS = 30;
+
+const API_BASE = "https://api.gofile.io";
+
+// Part of the website-token hash: the sent UA must be the hashed UA.
+const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
+const WT_LANG = "en-US";
+// No default WL_SALT. A hardcoded salt rots and every install fails at
+// once. The persisted value (userData/gofile-salt.json) is the source of
+// truth; with no stored value the first probe fetches WL_URLS live, takes
+// the top candidate, stores it, and proceeds with it below.
+const WL_URLS = [
+ "https://gofile.io/js/wt.obf.js",
+ "https://gofile.io/dist/js/wt.obf.js",
+];
+const WT_WINDOW_SECONDS = 14400;
+const id = "gofile";
+const label = "Gofile";
+const supportsAnonymous = true;
+
+// Store hosts serve picked files rather than share pages.
+function isStoreHost(host) {
+ const name = String(host || "");
+ return name.endsWith(".gofile.io") && name !== "api.gofile.io"
+ && name !== "gofile.io" && name !== "www.gofile.io";
+}
+
+function matches(url) {
+ let host = "";
+ try {
+ host = new URL(String(url || "")).hostname.toLowerCase();
+ } catch {
+ return false;
+ }
+ if (host === "gofile.io" || host === "www.gofile.io") return true;
+ // Store hosts serve picked files and need a cookie, so claim them to re-probe.
+ return isStoreHost(host);
+}
+
+function fileIdFrom(url) {
+ const text = String(url || "");
+ const path = text.match(/gofile\.io\/d\/([A-Za-z0-9]+)/i);
+ if (path) return path[1];
+ const param = text.match(/[?&]c=([A-Za-z0-9]+)/i);
+ return param ? param[1] : null;
+}
+
+function websiteToken(token, nowMs = Date.now(), salt = "") {
+ const window = Math.floor(nowMs / 1000 / WT_WINDOW_SECONDS);
+ return createHash("sha256")
+ .update(`${UA}::${WT_LANG}::${token}::${window}::${salt}`)
+ .digest("hex");
+}
+
+function userDataDir() {
+ // Test override; production resolves through Electron below.
+ if (process.env.ATLAS_USER_DATA) return process.env.ATLAS_USER_DATA;
+ try {
+ // Unavailable under plain node: callers then run memory-less, which only
+ // costs a refetch on rotation.
+ return require("electron").app.getPath("userData");
+ } catch {
+ return null;
+ }
+}
+
+function isValidSalt(value) {
+ return typeof value === "string" && /^[A-Za-z0-9]{8,64}$/.test(value);
+}
+
+// Last persisted salt, or null when there is none (fresh install, corrupt
+// file, or no userData dir). Never throws. Plain JSON, no encryption: the
+// salt is public (shipped in Gofile's own script) and anonymous downloads
+// must not depend on an OS keychain.
+function loadSalt() {
+ try {
+ const dir = userDataDir();
+ if (!dir) return null;
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, "gofile-salt.json"), "utf8"));
+ const salt = parsed && parsed.salt;
+ return isValidSalt(salt) ? salt : null;
+ } catch {
+ return null;
+ }
+}
+
+// Persist a server-validated salt. Best-effort: a failed write only means the
+// next probe refetches instead of reusing.
+function saveSalt(salt) {
+ if (!isValidSalt(salt)) return false;
+ try {
+ const dir = userDataDir();
+ if (!dir) return false;
+ const target = path.join(dir, "gofile-salt.json");
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, JSON.stringify({ salt }), { mode: 0o600 });
+ return true;
+ } catch (err) {
+ console.warn("[gofile-salt]", `persist failed for ${salt}:`, err?.message || err);
+ return false;
+ }
+}
+
+// Candidates scraped from the live script. The salt ships as one contiguous
+// \x run while obfuscator blobs are plain base64, so that pass isolates it;
+// hex-shaped then generic scans are fallback for a future non-hex shape.
+function extractSalts(script) {
+ const raw = String(script || "");
+ const found = [];
+ const push = (s) => { if (s && !found.includes(s)) found.push(s); };
+ const decode = (s) => s.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
+ const text = decode(raw);
+ for (const m of raw.matchAll(/((?:\\x[0-9a-fA-F]{2}){12,16})/g)) {
+ const s = decode(m[1]);
+ if (/^[0-9a-fA-F]+$/.test(s) && /[A-Za-z]/.test(s) && /[0-9]/.test(s)) push(s);
+ if (found.length >= 8) break;
+ }
+ if (found.length < 8) {
+ for (const m of text.matchAll(/[0-9a-fA-F]{12,16}/g)) {
+ const s = m[0];
+ if (!/[A-Za-z]/.test(s) || !/[0-9]/.test(s)) continue;
+ push(s);
+ if (found.length >= 8) break;
+ }
+ }
+ if (found.length < 8) {
+ for (const m of text.matchAll(/[A-Za-z0-9]{12,24}/g)) {
+ const s = m[0];
+ if (!/[A-Za-z]/.test(s) || !/[0-9]/.test(s)) continue;
+ push(s);
+ if (found.length >= 8) break;
+ }
+ }
+ return found.slice(0, 8);
+}
+
+// One shared fetch per rotation incident: concurrent probes reuse it instead
+// of each downloading the script. Browser headers: the script host WAFs bare
+// fetches. Empty when unreachable, degrading to the transient error below.
+let inflightSalts = null;
+
+async function refreshSalts(exclude = new Set()) {
+ if (!inflightSalts) {
+ inflightSalts = (async () => {
+ for (const scriptUrl of WL_URLS) {
+ try {
+ const res = await fetch(scriptUrl, {
+ cache: "no-store",
+ headers: {
+ "user-agent": UA,
+ referer: "https://gofile.io/",
+ origin: "https://gofile.io",
+ },
+ });
+ if (!res.ok) continue;
+ return extractSalts(await res.text());
+ } catch {
+ continue;
+ }
+ }
+ return [];
+ })().finally(() => { inflightSalts = null; });
+ }
+ return (await inflightSalts).filter((s) => !exclude.has(s));
+}
+
+function classifyError(err, { status = 0, body = null } = {}) { // Exact Gofile statuses only (see gofile.io/api status table). Anything
+ // unknown stays retryable and gets reported, never faked fatal/quota.
+ // Order matters: error-notPremium arrives as HTTP 401, so it must precede
+ // the 401 -> auth rule.
+ const s = String(body?.status || err?.gofileStatus || "");
+ if (/password/i.test(s)) return "fatal";
+ if (s === "error-notFound") return "fatal";
+ if (status === 404 || status === 410) return "fatal";
+ if (status === 429 || s === "error-rateLimit" || s === "error-limits") return "quota";
+ if (s === "error-notPremium") return "transient";
+ // Guest-only: no account-id or ownership calls exist here, so only token
+ // failures are reachable. Owner/accountId statuses are deliberately unmapped.
+ if (status === 401 || status === 403 || s === "error-token" || s === "error-wrongToken") return "auth";
+ return "transient";
+}
+
+// Advice only for API codes we know; unknown codes pass through untouched.
+const API_ADVICE = {
+ "error-rateLimit": "Rate limited. Wait 1-2 mins and try again.",
+ "error-limits": "Rate limited. Wait 1-2 mins and try again.",
+ "error-notFound": "The folder may have expired — check it in your browser.",
+ "error-wrongToken": "Session rejected. Try again — a fresh guest session is minted automatically.",
+ "error-token": "Session rejected. Try again — a fresh guest session is minted automatically.",
+};
+
+async function readJson(response) {
+ try {
+ return await response.json();
+ } catch {
+ return null;
+ }
+}
+
+// Network failures need a next step, not just the raw error. Guests get
+// ~20 API calls/min, and retry-mashing a throttle extends it.
+function netError(what, err) {
+ return `Could not reach Gofile ${what} (${err?.message || err}). Check your connection, wait a bit, then try again.`;
+}
+
+async function createGuestAccount() {
+ const response = await fetch(`${API_BASE}/accounts`, {
+ method: "POST",
+ headers: { "user-agent": UA, "content-type": "application/json" },
+ body: "{}",
+ });
+ const body = await readJson(response);
+ const token = body?.data?.token;
+ if (!response.ok || body?.status !== "ok" || !token) {
+ const err = new Error(body?.status && body.status !== "ok"
+ ? `Gofile returned ${body.status}`
+ : `Gofile returned ${response.status} creating a guest account`);
+ err.gofileStatus = body?.status || "";
+ throw err;
+ }
+ return { token, id: body?.data?.id || null };
+}
+
+// One guest token per session: each mint spends guest-budget calls (20/min).
+// A rejected token drops the cache and mints once (see token retry in probe).
+let cachedGuest = null;
+async function getGuestAccount() {
+ if (cachedGuest) return cachedGuest.account;
+ const account = await createGuestAccount();
+ cachedGuest = { account };
+ return account;
+}
+function dropGuestAccount() {
+ cachedGuest = null;
+}
+
+// Single listing attempt at one time window. Callers handle windows: giving
+// every candidate its own previous-window retry would double junk candidates
+// into a rate-limit incident.
+async function fetchListing(code, token, salt, nowMs) {
+ const params = new URLSearchParams({
+ contentFilter: "",
+ page: "1",
+ pageSize: "1000",
+ sortField: "createTime",
+ sortDirection: "-1",
+ });
+ const target = `${API_BASE}/contents/${code}?${params}`;
+ const response = await fetch(target, {
+ headers: {
+ "user-agent": UA,
+ authorization: `Bearer ${token}`,
+ "x-website-token": websiteToken(token, nowMs, salt),
+ "x-bl": WT_LANG,
+ origin: "https://gofile.io",
+ referer: "https://gofile.io/",
+ },
+ });
+ return { response, body: await readJson(response), requested: target };
+}
+
+// One file -> directUrl. Several files -> `choices` for the modal picker; a
+// folder reaching the queue is refused there, never silently first-filed.
+async function probe(url) {
+ const code = fileIdFrom(url);
+ let host = "";
+ try {
+ host = new URL(String(url || "")).hostname.toLowerCase();
+ } catch {
+ host = "";
+ }
+ if (!code) {
+ if (isStoreHost(host)) {
+ try {
+ const { token } = await getGuestAccount();
+ return {
+ ok: true,
+ diagnostic: { requested: String(url).split(/[?#]/)[0] },
+ directUrl: String(url).split(/[?#]/)[0],
+ fileName: "",
+ fileSize: 0,
+ headers: { cookie: `accountToken=${token}` },
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ kind: classifyError(err, { body: { status: err.gofileStatus || "" } }),
+ error: netError("for a guest session", err),
+ diagnostic: { requested: `${API_BASE}/accounts` },
+ };
+ }
+ }
+ return {
+ ok: false,
+ kind: "fatal",
+ error: "Atlas does not recognise this Gofile link format. Opening it in a "
+ + "browser will show whether the files are still there; please report it either way.",
+ };
+ }
+
+ let token;
+ try {
+ ({ token } = await getGuestAccount());
+ } catch (err) {
+ return {
+ ok: false,
+ kind: classifyError(err, { body: { status: err.gofileStatus || "" } }),
+ error: netError("for a guest session", err),
+ diagnostic: { requested: `${API_BASE}/accounts`, stage: "guest-account" },
+ };
+ }
+
+ let listing;
+ // Stored salt first; with none, fetch the live top seed and proceed with
+ // it — the listing below validates it, rotation heals a miss. Never saved
+ // here: only a server-validated salt may persist (see below).
+ const fromStore = loadSalt();
+ let stored = fromStore;
+ if (!stored) {
+ const fresh = await refreshSalts();
+ stored = fresh[0] || null;
+ if (!stored) {
+ return {
+ ok: false,
+ kind: "transient",
+ error: "Gofile isn't letting Atlas read this folder right now. They may have "
+ + "changed something on their end -- please report it so it can be fixed.",
+ diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "seed" },
+ };
+ }
+ }
+ console.log("[gofile-salt]", `probe ${code}: salt ${stored} (${fromStore ? "stored" : "live"})`);
+ const now = Date.now();
+ try {
+ listing = await fetchListing(code, token, stored, now);
+ } catch (err) {
+ return {
+ ok: false,
+ kind: classifyError(err),
+ error: netError("for this folder", err),
+ diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "listing" },
+ };
+ }
+ // Same hash, previous window: a boundary-crossing clock reads as a rotated
+ // salt, so rule skew out before paying for a script fetch.
+ if (/token/i.test(String(listing?.body?.status || ""))) {
+ // Cached token rejected (IP change, expiry): mint fresh once and retry.
+ // Anything else still failing is reported, never looped.
+ dropGuestAccount();
+ try {
+ ({ token } = await getGuestAccount());
+ listing = await fetchListing(code, token, stored, Date.now());
+ } catch (err) {
+ return {
+ ok: false,
+ kind: classifyError(err),
+ error: netError("for this folder", err),
+ diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "listing-retry" },
+ };
+ }
+ }
+ if (listing?.body?.status === "error-notPremium") {
+ try {
+ const prev = await fetchListing(code, token, stored, now - WT_WINDOW_SECONDS * 1000);
+ if (prev?.body?.status !== "error-notPremium") listing = prev;
+ } catch {
+ // Keep the original listing; the live retry below still runs.
+ }
+ }
+ // Stored salt rejected on both windows: refetch live and retry alternates,
+ // persisting the first server-validated one. A bad hash can also surface
+ // as rateLimit, or a bare HTTP 429 with no JSON body; both signal
+ // rotation, never a verdict.
+ const rejected = String(listing?.body?.status || "");
+ let rotated = false;
+ if (rejected === "error-notPremium" || rejected === "error-rateLimit" || rejected === "error-limits"
+ || listing?.response?.status === 429) {
+ console.log("[gofile-salt]", `probe ${code}: salt ${stored} rejected (${listing?.response?.status} ${rejected}), refetching live`);
+ const pending = await refreshSalts(new Set([stored]));
+ // One pause before trying: setup already spent guest-budget calls, and a
+ // throttled burst answers 429 to even the correct hash.
+ // ATLAS_GOFILE_PAUSE_MS overrides the wait (tests use 0).
+ if (pending.length) await new Promise((r) => setTimeout(r, Number(process.env.ATLAS_GOFILE_PAUSE_MS || 4000)));
+ const settle = async (when) => {
+ for (const salt of pending) {
+ let retry;
+ try {
+ retry = await fetchListing(code, token, salt, when);
+ } catch {
+ continue;
+ }
+ const verdict = String(retry?.body?.status || "");
+ // First throttled answer ends rotation: the budget is gone and every
+ // further try extends the wall. Null keeps the original listing.
+ if (!verdict || /token|ratelimit|limits/i.test(verdict) || retry?.response?.status === 429) {
+ return null;
+ }
+ if (verdict !== "error-notPremium") {
+ // Only a post-auth verdict proves the hash validated.
+ if (verdict === "ok" || /notfound|password/i.test(verdict)) {
+ saveSalt(salt);
+ console.log("[gofile-salt]", `probe ${code}: stored new salt ${salt}`);
+ }
+ return retry;
+ }
+ }
+ return null;
+ };
+ const settled = await settle(now);
+ if (settled) listing = settled;
+ rotated = true;
+ }
+ const { response, body, requested } = listing;
+ const diagnostic = { requested, code, status: response.status, gofileStatus: body?.status || "" };
+ // Happy path proves the salt too: persist it so the next probe starts from
+ // a known-good stored value. Skipped after a rotation, which already
+ // persisted its validated salt.
+ if (!rotated && body?.status === "ok" && loadSalt() !== stored) saveSalt(stored);
+
+ if (!response.ok || !body || body.status !== "ok") {
+ const status = String(body?.status || "");
+ if (/password/i.test(status)) {
+ return {
+ ok: false,
+ kind: "fatal",
+ error: "This Gofile folder is password-protected. Atlas cannot open those, "
+ + "so grab the file from your browser instead.",
+ diagnostic,
+ };
+ }
+ if (/notpremium/i.test(status)) {
+ return {
+ ok: false,
+ kind: "transient",
+ error: "Gofile isn't letting Atlas read this folder right now. They may have "
+ + "changed something on their end -- please report it so it can be fixed.",
+ diagnostic,
+ };
+ }
+ const kind = classifyError(null, { status: response.status, body });
+ const code = String(body?.status || response.status || "");
+ const advice = API_ADVICE[code] || (response.status === 429 ? API_ADVICE["error-rateLimit"] : "");
+ return {
+ ok: false,
+ kind,
+ error: `Gofile returned ${code || "an unreadable response"}` + (advice ? `. ${advice}` : ""),
+ diagnostic,
+ };
+ }
+
+ const children = Object.values(body?.data?.children || {});
+ const files = children.filter((child) => child?.type !== "folder" && child?.link);
+ if (files.length === 0) {
+ return {
+ ok: false,
+ kind: "fatal",
+ error: children.length > 0
+ ? "This Gofile folder holds only subfolders. Atlas reads one level, so open "
+ + "it in your browser and pick the file you want."
+ : "This Gofile folder is empty or expired.",
+ diagnostic,
+ };
+ }
+ if (files.length > 1) {
+ return {
+ ok: true,
+ diagnostic,
+ choices: files.map((file) => ({
+ name: String(file.name || "unnamed"),
+ size: Number(file.size) || 0,
+ directUrl: String(file.link),
+ })),
+ };
+ }
+
+ const file = files[0];
+ return {
+ ok: true,
+ diagnostic,
+ directUrl: String(file.link),
+ fileName: String(file.name || `gofile-${code}`),
+ fileSize: Number(file.size) || 0,
+ // Store checks the cookie, not the auth header. Without it: 401.
+ headers: { cookie: `accountToken=${token}` },
+ };
+}
+
+async function validate() {
+ return { ok: true, anonymous: true };
+}
+
+// Sum the last 30 days of the usage buckets in account details. Day values
+// are normally byte counts, but deeper (hourly) leaves are summed too so a
+// shape change undercounts nothing; undated leaves are skipped.
+function trafficUsed(history) {
+ if (!history || typeof history !== "object") return null;
+ const cutoff = Date.now() - TRAFFIC_WINDOW_DAYS * 24 * 60 * 60 * 1000;
+ const sumLeaves = (node, time) => {
+ const amount = typeof node === "number" ? node : (typeof node === "string" && node.trim() !== "" ? Number(node) : NaN);
+ if (Number.isFinite(amount)) return time >= cutoff ? amount : 0;
+ if (!node || typeof node !== "object") return 0;
+ let sub = 0;
+ for (const value of Object.values(node)) sub += sumLeaves(value, time);
+ return sub;
+ };
+ let used = 0;
+ for (const [year, months] of Object.entries(history)) {
+ if (!months || typeof months !== "object") continue;
+ for (const [month, days] of Object.entries(months)) {
+ if (!days || typeof days !== "object") continue;
+ for (const [day, bytes] of Object.entries(days)) {
+ used += sumLeaves(bytes, Date.UTC(Number(year), Number(month) - 1, Number(day)));
+ }
+ }
+ }
+ return used;
+}
+
+async function getQuota() {
+ try {
+ // Cached token: same IP attribution, one less budget call per check.
+ const { token, id } = await getGuestAccount();
+ if (!id) return { ok: false, error: "Could not read the quota response" };
+ const response = await fetch(`${API_BASE}/accounts/${id}`, {
+ headers: { "user-agent": UA, authorization: `Bearer ${token}` },
+ });
+ const body = await readJson(response);
+ const data = body?.data;
+ if (!response.ok || body?.status !== "ok" || !data) {
+ return { ok: false, error: "Could not read the quota response" };
+ }
+ const used = trafficUsed(data.ipTraffic);
+ if (used == null) return { ok: false, error: "Could not read the quota response" };
+ const cap = data.tier === "guest" ? GUEST_TRAFFIC_CAP : null;
+ return { ok: true, used, cap };
+ } catch (err) {
+ return { ok: false, error: err.message || String(err) };
+ }
+}
+
+module.exports = {
+ id,
+ label,
+ supportsAnonymous,
+ quotaWithoutAccount: true,
+ quotaTemplate: `Transfer used: {used} of {cap} per ${TRAFFIC_WINDOW_DAYS} days`,
+ hostAliases: ["gofile"],
+ credentialFields: [],
+ matches,
+ probe,
+ validate,
+ getQuota,
+ classifyError,
+ fileIdFrom,
+ websiteToken,
+ saltStore: { load: loadSalt, save: saveSalt, refresh: refreshSalts, extractSalts, resetGuest: dropGuestAccount },
+};
diff --git a/electron/downloads/hosts/index.js b/electron/downloads/hosts/index.js
index 62c89f4..2e63ee2 100644
--- a/electron/downloads/hosts/index.js
+++ b/electron/downloads/hosts/index.js
@@ -32,10 +32,11 @@
const pixeldrain = require("./pixeldrain");
const buzzheavier = require("./buzzheavier");
const mega = require("./mega");
+const gofile = require("./gofile");
// Order matters only for overlapping matchers, of which there are none: each
-// plugin claims a distinct domain. Add Gofile and Mega here as they land.
-const plugins = [pixeldrain, buzzheavier, mega];
+// plugin claims a distinct domain.
+const plugins = [pixeldrain, buzzheavier, mega, gofile];
// A plugin can be present but not offered. `disabled: true` keeps it resolvable
// for a download already in the queue while removing it from everything that
@@ -85,6 +86,8 @@ function listPlugins() {
id: plugin.id,
label: plugin.label,
supportsAnonymous: plugin.supportsAnonymous !== false,
+ quotaWithoutAccount: plugin.quotaWithoutAccount === true,
+ quotaTemplate: plugin.quotaTemplate || null,
}));
}
diff --git a/electron/ipc/downloads.js b/electron/ipc/downloads.js
index 87edaeb..c2d73a4 100644
--- a/electron/ipc/downloads.js
+++ b/electron/ipc/downloads.js
@@ -156,6 +156,24 @@ function registerDownloadsHandlers(ctx = {}) {
}
});
+ // Folder listing for the update modal's picker. The modal cannot probe itself
+ // (renderer has no plugin access), and the queue only takes single files.
+ ipcMain.handle("downloads-list-folder", async (event, { url } = {}) => {
+ try {
+ if (!url) return { ok: false, error: "No URL supplied" };
+ const plugin = pluginFor(url);
+ if (!plugin) return { ok: false, error: "No plugin for this host" };
+ const result = await plugin.probe(url, credentialStore.getCredentials(plugin.id));
+ if (!result?.ok) return { ok: false, error: result?.error || "Could not read this folder" };
+ if (result.choices) return { ok: true, choices: result.choices };
+ // Single file: hand the direct URL back so the modal queues it without
+ // a second probe spending guest budget.
+ return { ok: true, directUrl: result.directUrl, fileName: result.fileName, fileSize: result.fileSize };
+ } catch (err) {
+ return { ok: false, error: err.message || String(err) };
+ }
+ });
+
// Version suggestion for the install prompt. Lives here rather than in the
// renderer because it reconciles the archive filename against the catalog's
// version, and the parser and the record both sit on this side.
diff --git a/electron/ipc/updateLinks.js b/electron/ipc/updateLinks.js
index 0e9d6ed..dde582f 100644
--- a/electron/ipc/updateLinks.js
+++ b/electron/ipc/updateLinks.js
@@ -214,6 +214,7 @@ function registerUpdateLinkHandlers() {
if (!threadId) return { ok: false, error: "No F95 thread id for this game" };
return await getUpdateLinks(threadId, { force });
} catch (err) {
+ console.warn(`update-links-get failed for thread ${threadId ?? '?'}:`, err.message || err);
return { ok: false, code: err.code || "", error: err.message || String(err) };
}
});
diff --git a/electron/preload.js b/electron/preload.js
index 17a3935..58af05f 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -366,6 +366,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
downloadsOpenFolder: () => ipcRenderer.invoke("downloads-open-folder"),
downloadsAttachFile: (params) => ipcRenderer.invoke("downloads-attach-file", params),
downloadsResolveMasked: (params) => ipcRenderer.invoke("downloads-resolve-masked", params),
+ downloadsListFolder: (params) => ipcRenderer.invoke("downloads-list-folder", params),
downloadsInstall: (params) => ipcRenderer.invoke("downloads-install", params),
downloadsSuggestVersion: (params) => ipcRenderer.invoke("downloads-suggest-version", params),
hostsList: () => ipcRenderer.invoke("hosts-list"),
diff --git a/scripts/check-host-plugins.js b/scripts/check-host-plugins.js
index 49c694a..3b1ddc4 100644
--- a/scripts/check-host-plugins.js
+++ b/scripts/check-host-plugins.js
@@ -14,6 +14,7 @@
const assert = require("assert");
const pixeldrain = require("../electron/downloads/hosts/pixeldrain");
const buzzheavier = require("../electron/downloads/hosts/buzzheavier");
+const gofile = require("../electron/downloads/hosts/gofile");
const registry = require("../electron/downloads/hosts");
const { selectDownloadableLinks } = require("../electron/downloads/groupClassifier");
@@ -453,13 +454,103 @@ const ok = (condition, message) => { assert.ok(condition, message); checks += 1;
// it would need "buzz" in the supported set, and no plugin has ever claimed
// it. Asserted so a future alias cannot reintroduce it by accident.
ok(!registry.supportedHostIds().includes("buzz"), "buzz.to is not offered either");
- // pixeldrain, mega, and buzzheavier (with its bzzhr alias).
- eq(registry.supportedHostIds().length, 4, "four offered host labels");
+ // pixeldrain, mega, buzzheavier (with its bzzhr alias), and gofile.
+ eq(registry.supportedHostIds().length, 5, "five offered host labels");
ok(registry.supportedHostIds().includes("mega"), "mega is offered");
} finally {
global.fetch = realFetch;
}
+ // ── Gofile ──────────────────────────────────────────────────────────────
+ // Share pages are JS shells, so the plugin reads the folder through the same
+ // API the web client uses. These pin recognition, the token request shape,
+ // and the one-file vs multi-file split the modal picker depends on.
+ ok(gofile.matches("https://gofile.io/d/AbCdEfGh"), "share page matched");
+ ok(gofile.matches("https://store1.gofile.io/download/web/u1/game.zip"), "store host claimed for a fresh cookie");
+ ok(!gofile.matches("https://pixeldrain.com/u/x"), "other hosts not claimed");
+ eq(gofile.fileIdFrom("https://gofile.io/d/AbCdEfGh"), "AbCdEfGh", "id extracted");
+ eq(gofile.fileIdFrom("https://gofile.io/?c=AbCd12"), "AbCd12", "legacy param id extracted");
+ eq(gofile.fileIdFrom("https://gofile.io/pricing"), null, "site route is not a folder id");
+ eq(gofile.classifyError(null, { status: 429 }), "quota", "429 is quota");
+ eq(gofile.classifyError(null, { body: { status: "error-notFound" } }), "fatal", "notFound is fatal");
+ eq(gofile.classifyError(new Error("ECONNRESET")), "transient", "socket error retries");
+ eq(gofile.quotaWithoutAccount, true, "quota loads with no account saved");
+ // The renderer never sees the module, only the serialized list shape.
+ eq(registry.listPlugins().find((p) => p.id === "gofile")?.quotaWithoutAccount, true, "flag survives listPlugins");
+ eq(registry.listPlugins().find((p) => p.id === "gofile")?.quotaTemplate, "Transfer used: {used} of {cap} per 30 days", "template survives listPlugins");
+
+ try {
+ const child = (name, size, link) => ({ id: "u", type: "file", name, size, link });
+ // Seedless probes fetch the script first; shared so each stub needn't.
+ const withScript = (handler) => stub(async (url, init) => {
+ if (String(url).includes("wt.obf.js")) {
+ return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' };
+ }
+ return handler(url, init);
+ });
+ {
+ let seen = null;
+ withScript(async (url, init) => {
+ if (String(url).includes("/accounts")) {
+ seen = { url, init };
+ return jsonResponse({ status: "ok", data: { token: "guest-1", id: "account-1" } });
+ }
+ return jsonResponse({ status: "ok", data: { children: {
+ a: child("game.zip", 7, "https://store1.gofile.io/download/web/u/game.zip"),
+ } } });
+ });
+ const result = await gofile.probe("https://gofile.io/d/AbCdEfGh");
+ eq(result.ok, true, "one-file folder resolves");
+ eq(seen.init.method, "POST", "guest account created first");
+ eq(result.directUrl, "https://store1.gofile.io/download/web/u/game.zip", "child link used verbatim");
+ eq(result.headers.cookie, "accountToken=guest-1", "store cookie carried to the transfer");
+ }
+ {
+ withScript(async (url) => {
+ if (String(url).includes("/accounts")) {
+ return jsonResponse({ status: "ok", data: { token: "guest-1" } });
+ }
+ return jsonResponse({ status: "ok", data: { children: {
+ a: child("part1.zip", 1, "https://store1.gofile.io/1"),
+ b: child("part2.zip", 2, "https://store1.gofile.io/2"),
+ } } });
+ });
+ const result = await gofile.probe("https://gofile.io/d/AbCdEfGh");
+ eq(result.ok, true, "multi-file folder resolves");
+ eq(result.directUrl, undefined, "no silent first-file grab");
+ eq(result.choices.length, 2, "both files offered to the picker");
+ }
+ {
+ withScript(async (url) => {
+ if (String(url).includes("/accounts")) {
+ return jsonResponse({ status: "ok", data: { token: "guest-1" } });
+ }
+ throw new Error(`unstubbed fetch: ${url}`);
+ });
+ const result = await gofile.probe("https://store1.gofile.io/download/web/u/game.zip");
+ eq(result.ok, true, "store file resolves");
+ eq(result.headers.cookie, "accountToken=guest-1", "store transfer carries a cookie");
+ }
+ eq(registry.pluginFor("https://gofile.io/d/AbCdEfGh")?.id, "gofile", "routed");
+ eq(registry.pluginFor("https://store1.gofile.io/download/web/u/game.zip")?.id, "gofile", "picked file re-probes");
+ ok(registry.supportedHostIds().includes("gofile"), "offered as a mirror");
+ {
+ // Fresh guest: the quota block must not depend on probe-block leftovers.
+ gofile.saltStore.resetGuest();
+ stub(async (url) => {
+ if (String(url).includes("/accounts/")) {
+ return jsonResponse({ status: "ok", data: { tier: "guest", ipTraffic: {} } });
+ }
+ return jsonResponse({ status: "ok", data: { token: "guest-1", id: "account-1" } });
+ });
+ const quota = await gofile.getQuota();
+ eq(quota.ok, true, "free usage readable");
+ eq(quota.cap, 1000000000000, "free allowance is 1 TB");
+ }
+ } finally {
+ global.fetch = realFetch;
+ }
+
console.log(`Host plugin checks passed (${checks} assertions)`);
})().catch((err) => {
console.error(err);
diff --git a/src/components/downloads/UpdateModal.jsx b/src/components/downloads/UpdateModal.jsx
index aa55783..fdd7691 100644
--- a/src/components/downloads/UpdateModal.jsx
+++ b/src/components/downloads/UpdateModal.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { Fragment, useCallback, useEffect, useState } from 'react'
import HostIcon from './HostIcon.jsx'
import { buildThreadUrl, threadUrlForGame } from './threadUrl.js'
import { buildDownloadOptions } from './linkSections.js'
@@ -34,12 +34,22 @@ import { buildDownloadOptions } from './linkSections.js'
const prettyHost = (host) => String(host || '').replace(/^www\./, '')
+const formatBytes = (value) => {
+ const bytes = Number(value) || 0
+ if (bytes <= 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
+ const scaled = bytes / 1024 ** index
+ return `${scaled.toFixed(index === 0 ? 0 : scaled >= 10 ? 1 : 2)} ${units[index]}`
+}
+
export default function UpdateModal({ game, open, onClose, onQueued, session = null }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [errorCode, setErrorCode] = useState('')
const [data, setData] = useState(null)
const [resolvingUrl, setResolvingUrl] = useState('')
+ const [folderChoices, setFolderChoices] = useState(null)
const threadId = game?.f95_id || game?.f95Id || null
const title = game?.title || 'this game'
@@ -81,26 +91,13 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n
useEffect(() => {
if (open) load(false)
- else { setData(null); setError(''); setResolvingUrl('') }
+ else { setData(null); setError(''); setResolvingUrl(''); setFolderChoices(null) }
}, [open, load])
// Resolving opens a real browser window where the user clears F95's gate
// themselves. Atlas reads the destination and queues it.
- const choose = async (link) => {
- setResolvingUrl(link.url)
- setError('')
- try {
- const resolved = await window.electronAPI.downloadsResolveMasked?.({
- url: link.url,
- title,
- })
- if (!resolved?.ok) {
- if (!resolved?.canceled) {
- setError(resolved?.error || 'Could not get the download link')
- }
- return
- }
- const queued = await window.electronAPI.downloadsEnqueue?.({
+ const queueDownload = async (link, url, host) => {
+ const queued = await window.electronAPI.downloadsEnqueue?.({
// Every browse row already knows whether it is in the library:
// local_record_id is projected as localRecordId in all four branches of
// the catalog union, resolved from the atlas / f95 / lewdcorner / steam
@@ -125,8 +122,8 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n
title,
creator: game?.creator || '',
version: game?.latestVersion || game?.latest_version || '',
- url: resolved.url,
- host: resolved.host || link.host,
+ url,
+ host,
source: 'f95',
// Which build this is, in the poster's own words. The queue otherwise
// shows the game title and the LATEST version on every row, so an old
@@ -144,6 +141,7 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n
onComplete: 'replace',
})
if (queued?.success) {
+ setFolderChoices(null)
onQueued?.(queued.item)
// In a session the parent advances to the next game, which unmounts
// this content anyway. Closing here as well would race that and leave
@@ -152,6 +150,49 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n
} else {
setError(queued?.error || 'Could not add this to the download queue')
}
+ }
+
+ const choose = async (link) => {
+ setResolvingUrl(link.url)
+ setError('')
+ setFolderChoices(null)
+ try {
+ const resolved = await window.electronAPI.downloadsResolveMasked?.({
+ url: link.url,
+ title,
+ })
+ if (!resolved?.ok) {
+ if (!resolved?.canceled) {
+ setError(resolved?.error || 'Could not get the download link')
+ }
+ return
+ }
+ // The listing decides: choices present means pick, anything else queues.
+ // Hosts with no plugin fall through to the queue instead of erroring.
+ const folder = await window.electronAPI.downloadsListFolder?.({ url: resolved.url })?.catch(() => null)
+ if (folder?.ok && folder.choices?.length) {
+ setFolderChoices({ linkUrl: link.url, link, resolved, choices: folder.choices })
+ return
+ }
+ if (folder && !folder.ok && !/no plugin/i.test(folder.error || '')) {
+ setError(folder.error || 'Could not read this folder')
+ return
+ }
+ await queueDownload(link, folder?.directUrl || resolved.url, resolved.host || link.host)
+ } catch (err) {
+ setError(err.message || 'Could not start this download')
+ } finally {
+ setResolvingUrl('')
+ }
+ }
+
+ const chooseFile = async (choice) => {
+ const picked = folderChoices
+ if (!picked) return
+ setResolvingUrl(picked.linkUrl)
+ setError('')
+ try {
+ await queueDownload(picked.link, choice.directUrl, picked.resolved.host || picked.link.host)
} catch (err) {
setError(err.message || 'Could not start this download')
} finally {
@@ -376,8 +417,8 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n
{option.links.map((link) => {
const busy = resolvingUrl === link.url
return (
+
- Transfer used {formatBytes(quota.used)} - {quota.cap != null && ` of ${formatBytes(quota.cap)}`} + {formatQuota(plugin, quota)}
)} @@ -190,6 +211,21 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { {hasAccount ? 'Replace' : 'Add account'} )} + {fields.length === 0 && !hasAccount && ( + + + + )} @@ -256,6 +292,7 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { {/* The modal owns the error while it is open, and a failed save keeps it open, so the card only reports the outcome that closed it. */} {notice &&{notice}
} + {infoMsg &&{infoMsg}
} {modalOpen && (