- 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 && (
{
fireEvent.click(button)
}
+const gofileCard = {
+ id: 'gofile',
+ label: 'Gofile',
+ supportsAnonymous: true,
+ quotaWithoutAccount: true,
+ quotaTemplate: 'Transfer used: {used} of {cap} per 30 days',
+ hasAccount: false,
+ credentialFields: [],
+}
+
+const mockGofileCard = () => {
+ window.electronAPI.hostsList = vi.fn().mockResolvedValue({
+ ok: true, available: true, plugins: [gofileCard], accounts: [],
+ })
+}
+
describe('DownloadAccounts host form', () => {
it('shows no form until the button is pressed', async () => {
render()
@@ -127,4 +143,60 @@ describe('DownloadAccounts host form', () => {
expect(await screen.findByText('MEGA rejected that password.')).toBeTruthy()
expect(screen.getByLabelText(/Email/)).toBeTruthy()
})
+
+ it('shows an Add account that explains login is unsupported', async () => {
+ mockGofileCard()
+ // 4.4 GB used of a 1 TB allowance, decimal units like gofile.io.
+ window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({
+ ok: true, used: 4400000000, cap: 1000000000000,
+ })
+ render()
+ expect(await screen.findByText('Transfer used: 4.4 GB of 1.0 TB per 30 days')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'Add account' }))
+ expect(await screen.findByText('Account login is not supported for this host yet')).toBeTruthy()
+ })
+
+ it('dismisses the unsupported-login message after a few seconds', async () => {
+ mockGofileCard()
+ window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({ ok: false })
+ render()
+ const addButton = await screen.findByRole('button', { name: 'Add account' })
+ vi.useFakeTimers()
+ try {
+ fireEvent.click(addButton)
+ expect(screen.getByText('Account login is not supported for this host yet')).toBeTruthy()
+ act(() => { vi.advanceTimersByTime(4000) })
+ expect(screen.queryByText('Account login is not supported for this host yet')).toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('renders used-only quota when the host reports no cap or template', async () => {
+ window.electronAPI.hostsList = vi.fn().mockResolvedValue({
+ ok: true,
+ available: true,
+ plugins: [{
+ id: 'gofile',
+ label: 'Gofile',
+ supportsAnonymous: true,
+ quotaWithoutAccount: true,
+ hasAccount: false,
+ credentialFields: [],
+ }],
+ accounts: [],
+ })
+ window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({ ok: true, used: 4400000000, cap: null })
+ render()
+ expect(await screen.findByText('Transfer used 4.4 GB')).toBeTruthy()
+ })
+
+ it('does not fetch quota for a login host with no account', async () => {
+ render()
+ // The default plugins fixture is mega without an account: quota stays
+ // unfetched rather than failing against a missing session.
+ await screen.findByRole('button', { name: 'Add account' })
+ await waitFor(() => expect(window.electronAPI.hostsList).toHaveBeenCalled())
+ expect(window.electronAPI.hostsQuota).not.toHaveBeenCalled()
+ })
})
diff --git a/tests/download-manager-folders.test.js b/tests/download-manager-folders.test.js
new file mode 100644
index 00000000..7896fd7e
--- /dev/null
+++ b/tests/download-manager-folders.test.js
@@ -0,0 +1,112 @@
+import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
+import Module from 'node:module'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+// The queue's safety net for a folder URL that reaches it unpicked: fail
+// fatal with "pick one", never silently first-file. Runs the real manager
+// against a temp database with fetch stubbed (electron stubbed too).
+
+process.env.ATLAS_GOFILE_PAUSE_MS = '0'
+
+const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-dm-folders-'))
+
+const electronStub = {
+ ipcMain: { handle: () => {} },
+ shell: {},
+ BrowserWindow: { getAllWindows: () => [] },
+ app: { getPath: () => dataDir },
+ dialog: {},
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (s) => Buffer.from(String(s)),
+ decryptString: (b) => String(b),
+ },
+}
+
+const originalLoad = Module._load
+Module._load = function (request, ...rest) {
+ if (request === 'electron') return electronStub
+ return originalLoad.call(this, request, ...rest)
+}
+
+const dbIndex = require('../electron/db/index.js')
+const downloadsDb = require('../electron/db/downloads.js')
+const manager = require('../electron/downloads/downloadManager.js')
+const gofile = require('../electron/downloads/hosts/gofile.js')
+
+Module._load = originalLoad
+
+const json = (body, status = 200) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+})
+
+const realFetch = globalThis.fetch
+afterEach(() => {
+ globalThis.fetch = realFetch
+ delete process.env.ATLAS_USER_DATA
+ gofile.saltStore.resetGuest()
+})
+afterAll(() => {
+ try {
+ fs.rmSync(dataDir, { recursive: true, force: true })
+ } catch {
+ // Best-effort temp cleanup only.
+ }
+})
+
+const waitForState = async (id, want, tries = 100) => {
+ for (let i = 0; i < tries; i += 1) {
+ const item = await downloadsDb.getDownload(id)
+ if (item?.state === want) return item
+ await new Promise((r) => setTimeout(r, 50))
+ }
+ throw new Error(`download ${id} never reached ${want}`)
+}
+
+describe('download manager folder guard', () => {
+ beforeAll(async () => {
+ dbIndex.initializeDatabase(dataDir)
+ await downloadsDb.initializeDownloads()
+ manager.configure({
+ onEvent: () => {},
+ resolveDownloadsDir: () => dataDir,
+ resolveHostCredentials: () => ({}),
+ })
+ })
+
+ it('fails a multi-file folder with a pick-one message, never first-files it', async () => {
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' }
+ }
+ if (text.includes('/accounts')) {
+ return json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } })
+ }
+ if (text.includes('/contents/')) {
+ return json({
+ status: 'ok',
+ data: {
+ children: {
+ a: { id: 'u1', type: 'file', name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' },
+ b: { id: 'u2', type: 'file', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' },
+ },
+ },
+ })
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const queued = await manager.enqueue({
+ title: 'Season 2',
+ url: 'https://gofile.io/d/AbCdEfGh',
+ host: 'gofile',
+ })
+ expect(queued.success).toBe(true)
+ const item = await waitForState(queued.id, 'failed')
+ expect(item.error).toMatch(/2 files\. Pick one/i)
+ })
+})
diff --git a/tests/downloads-list-folder.test.js b/tests/downloads-list-folder.test.js
new file mode 100644
index 00000000..57b88629
--- /dev/null
+++ b/tests/downloads-list-folder.test.js
@@ -0,0 +1,141 @@
+import { describe, it, expect, afterEach } from 'vitest'
+import Module from 'node:module'
+import path from 'node:path'
+
+const gofile = require('../electron/downloads/hosts/gofile.js')
+
+// The list-folder handler is thin glue, but it owns the modal's whole
+// contract: choices for the picker vs a single-file directUrl that skips a
+// second probe. Tested through the real registered handler: electron is
+// stubbed, the plugin registry is real, only fetch is faked.
+//
+// Rotation pauses 4s before trying candidates (guest-budget discipline);
+// tests exercise logic, not timing.
+process.env.ATLAS_GOFILE_PAUSE_MS = '0'
+
+const handlers = new Map()
+const electronStub = {
+ ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
+ shell: {},
+ BrowserWindow: { getAllWindows: () => [] },
+ app: { getPath: () => 'C:\\tmp\\atlas-test' },
+ dialog: {},
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (s) => Buffer.from(String(s)),
+ decryptString: (b) => String(b),
+ },
+}
+
+const originalLoad = Module._load
+Module._load = function (request, ...rest) {
+ if (request === 'electron') return electronStub
+ return originalLoad.call(this, request, ...rest)
+}
+require('../electron/ipc/downloads.js')({})
+Module._load = originalLoad
+
+const listFolder = (args) => handlers.get('downloads-list-folder')({}, args)
+
+const realFetch = globalThis.fetch
+afterEach(() => {
+ globalThis.fetch = realFetch
+ delete process.env.ATLAS_USER_DATA
+ gofile.saltStore.resetGuest()
+})
+
+const json = (body, status = 200) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+})
+const guestAccount = () => json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } })
+const child = (over = {}) => ({
+ id: 'u1', type: 'file', name: 'game.zip', size: 42,
+ link: 'https://store1.gofile.io/download/web/u1/game.zip',
+ ...over,
+})
+const folder = (children) => json({ status: 'ok', data: { children } })
+
+function stubFetch(routes) {
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' }
+ }
+ const ordered = [...routes].sort((a, b) => b[0].length - a[0].length)
+ for (const [match, response] of ordered) {
+ if (text.includes(match)) return response
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+}
+
+describe('downloads-list-folder', () => {
+ it('is registered on the real ipc module', () => {
+ expect(handlers.has('downloads-list-folder')).toBe(true)
+ })
+
+ it('refuses a missing url without touching the network', async () => {
+ let fetched = false
+ globalThis.fetch = async () => { fetched = true; throw new Error('must not fetch') }
+ expect(await listFolder()).toEqual({ ok: false, error: 'No URL supplied' })
+ expect(await listFolder({})).toEqual({ ok: false, error: 'No URL supplied' })
+ expect(fetched).toBe(false)
+ })
+
+ it('names the missing plugin instead of probing blindly', async () => {
+ expect(await listFolder({ url: 'https://example.com/file.zip' }))
+ .toEqual({ ok: false, error: 'No plugin for this host' })
+ })
+
+ it('passes a probe failure through with its message', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', json({ status: 'error-notFound' }, 404)],
+ ])
+ const result = await listFolder({ url: 'https://gofile.io/d/deadbeef' })
+ expect(result.ok).toBe(false)
+ expect(result.error).toContain('error-notFound')
+ })
+
+ it('returns choices for a multi-file folder', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', folder({
+ a: child({ name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' }),
+ b: child({ id: 'u2', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' }),
+ })],
+ ])
+ const result = await listFolder({ url: 'https://gofile.io/d/AbCdEfGh' })
+ expect(result.ok).toBe(true)
+ expect(result.choices).toHaveLength(2)
+ expect(result.directUrl).toBeUndefined()
+ })
+
+ it('passes a single file through with one listing call, not two', async () => {
+ // The modal queues this directUrl without re-probing: every extra
+ // listing spends from the 20/min guest budget.
+ let listings = 0
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ listings += 1
+ return folder({ a: child() })
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await listFolder({ url: 'https://gofile.io/d/AbCdEfGh' })
+ expect(result).toEqual({
+ ok: true,
+ directUrl: 'https://store1.gofile.io/download/web/u1/game.zip',
+ fileName: 'game.zip',
+ fileSize: 42,
+ })
+ expect(listings).toBe(1)
+ })
+})
diff --git a/tests/gofile.test.js b/tests/gofile.test.js
new file mode 100644
index 00000000..1d58d82a
--- /dev/null
+++ b/tests/gofile.test.js
@@ -0,0 +1,570 @@
+import { describe, it, expect, afterEach } from 'vitest'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const gofile = require('../electron/downloads/hosts/gofile.js')
+
+// Rotation pauses 4s before trying candidates (guest-budget discipline);
+// tests exercise logic, not timing.
+process.env.ATLAS_GOFILE_PAUSE_MS = '0'
+
+// Gofile shares are folders, and folders need a listing before anything can be
+// queued. These pin the listing contract with fetch stubbed: which URLs the
+// plugin claims, what a one-file folder resolves to, and what a multi-file
+// folder returns for the modal picker.
+
+const realFetch = globalThis.fetch
+const tempDirs = []
+afterEach(() => {
+ globalThis.fetch = realFetch
+ delete process.env.ATLAS_USER_DATA
+ gofile.saltStore.resetGuest()
+ while (tempDirs.length) {
+ try {
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true })
+ } catch {
+ // Best-effort temp cleanup only.
+ }
+ }
+})
+
+const json = (body, status = 200) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+})
+
+function stubFetch(routes) {
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ // Seedless probes fetch the live top seed first; answer it deterministically.
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' }
+ }
+ // Longest match wins, so '/accounts' can't shadow '/accounts/account-1'
+ // regardless of route order.
+ const ordered = [...routes].sort((a, b) => b[0].length - a[0].length)
+ for (const [match, response] of ordered) {
+ if (text.includes(match)) return response
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+}
+
+const guestAccount = () => json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } })
+const child = (over = {}) => ({
+ id: 'u1', type: 'file', name: 'game.zip', size: 42,
+ link: 'https://store1.gofile.io/download/web/u1/game.zip',
+ ...over,
+})
+const folder = (children) => json({ status: 'ok', data: { children } })
+
+describe('matches', () => {
+ it('claims share pages', () => {
+ expect(gofile.matches('https://gofile.io/d/AbCdEfGh')).toBe(true)
+ expect(gofile.matches('https://www.gofile.io/d/AbCdEfGh')).toBe(true)
+ })
+
+ it('claims store hosts so picked files re-probe with a cookie', () => {
+ expect(gofile.matches('https://store1.gofile.io/download/web/u1/game.zip')).toBe(true)
+ expect(gofile.matches('https://srv-store2.gofile.io/download/web/u1/game.zip')).toBe(true)
+ })
+
+ it('does not claim other hosts or garbage', () => {
+ expect(gofile.matches('https://pixeldrain.com/u/x')).toBe(false)
+ expect(gofile.matches('not a url')).toBe(false)
+ })
+})
+
+describe('fileIdFrom', () => {
+ it('reads the share code', () => {
+ expect(gofile.fileIdFrom('https://gofile.io/d/AbCdEfGh')).toBe('AbCdEfGh')
+ expect(gofile.fileIdFrom('https://gofile.io/?c=AbCd12')).toBe('AbCd12')
+ })
+
+ it('returns null when there is no code', () => {
+ expect(gofile.fileIdFrom('https://gofile.io/')).toBeNull()
+ })
+})
+
+describe('websiteToken', () => {
+ it('is deterministic for the same input', () => {
+ const now = 1725897600000
+ expect(gofile.websiteToken('tok', now)).toBe(gofile.websiteToken('tok', now))
+ expect(gofile.websiteToken('tok', now)).toMatch(/^[0-9a-f]{64}$/)
+ })
+
+ it('changes across windows', () => {
+ const now = 1725897600000
+ expect(gofile.websiteToken('tok', now)).not.toBe(gofile.websiteToken('tok', now + 14400 * 1000))
+ })
+})
+
+describe('classifyError', () => {
+ it('treats a bare 404 as fatal', () => {
+ expect(gofile.classifyError(null, { status: 404, body: null })).toBe('fatal')
+ })
+
+ it('treats a rejected token as auth', () => {
+ expect(gofile.classifyError(null, { body: { status: 'error-wrongToken' } })).toBe('auth')
+ })
+
+ it('treats a rejected signature as transient', () => {
+ expect(gofile.classifyError(null, { status: 401, body: { status: 'error-notPremium' } })).toBe('transient')
+ })
+
+ it('treats throttling as quota, never fatal', () => {
+ // Load-bearing for the rotation stop: a quota verdict ends the loop,
+ // anything else keeps burning budget.
+ expect(gofile.classifyError(null, { status: 429, body: null })).toBe('quota')
+ expect(gofile.classifyError(null, { body: { status: 'error-rateLimit' } })).toBe('quota')
+ expect(gofile.classifyError(null, { body: { status: 'error-limits' } })).toBe('quota')
+ })
+})
+
+describe('extractSalts', () => {
+ it('ranks the live \\x-escaped salt first among obfuscator junk', () => {
+ const { extractSalts } = gofile.saltStore
+ const script = 'a="W71SCxpdL8kLFmklW34eF" k=\'\\x48\\x77\\x45\\x6e\\x6b\':\'\\x31\\x32\\x61\\x66\\x30'
+ + '\\x35\\x36\\x64\\x61\\x63\\x65\\x61\\x30\\x62\' b="junk0000000001" c="junk0000000002"'
+ expect(extractSalts(script)[0]).toBe('12af056dacea0b')
+ })
+})
+
+describe('probe', () => {
+ it('resolves a one-file folder to a direct url', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', folder({ a: child() })],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip')
+ expect(result.fileName).toBe('game.zip')
+ expect(result.fileSize).toBe(42)
+ expect(result.headers.cookie).toBe('accountToken=guest-token')
+ })
+
+ it('probes a store file link with a fresh guest cookie', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ])
+ const result = await gofile.probe('https://store1.gofile.io/download/web/u1/game.zip')
+ expect(result.ok).toBe(true)
+ expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip')
+ expect(result.headers.cookie).toBe('accountToken=guest-token')
+ })
+
+ it('returns choices for a multi-file folder, not a direct url', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', folder({
+ a: child({ name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' }),
+ b: child({ id: 'u2', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' }),
+ })],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(result.directUrl).toBeUndefined()
+ expect(result.choices).toHaveLength(2)
+ expect(result.choices[0]).toEqual({ name: 'part1.zip', size: 10, directUrl: 'https://store1.gofile.io/1' })
+ })
+
+ it('fails a missing folder without retrying', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', json({ status: 'error-notFound' }, 404)],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/deadbeef')
+ expect(result.ok).toBe(false)
+ expect(result.kind).toBe('fatal')
+ expect(result.error).toContain('error-notFound')
+ })
+
+ it('pairs a throttled verdict with wait advice when recovery finds nothing', async () => {
+ // Stored salt rejected, script unreachable: the original 429 surfaces
+ // with its meaning attached, not bare.
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' }))
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) return json({ status: 'error-rateLimit' }, 429)
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(result.error).toContain('error-rateLimit')
+ expect(result.error).toMatch(/wait 1-2 mins/i)
+ })
+
+ it('passes unknown API codes through with no invented advice', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', json({ status: 'error-teapot' }, 400)],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(result.error).toBe('Gofile returned error-teapot')
+ })
+
+ it('refuses password-protected folders', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', json({ status: 'error-passwordRequired' }, 403)],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/locked12')
+ expect(result.ok).toBe(false)
+ expect(result.kind).toBe('fatal')
+ expect(result.error).toMatch(/password/i)
+ })
+
+ it('names Atlas as the limitation on an unrecognised link', async () => {
+ const result = await gofile.probe('https://gofile.io/pricing')
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/atlas/i)
+ })
+
+ it('calls an empty folder empty or expired, not a failure', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', folder({})],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(result.kind).toBe('fatal')
+ expect(result.error).toMatch(/empty or expired/i)
+ })
+
+ it('refuses a subfolders-only folder instead of first-filing it', async () => {
+ stubFetch([
+ ['/accounts', guestAccount()],
+ ['/contents/', folder({ a: { id: 'u1', type: 'folder', name: 'sub', link: 'https://gofile.io/d/subfold1' } })],
+ ])
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(result.kind).toBe('fatal')
+ expect(result.error).toMatch(/only subfolders/i)
+ })
+
+ it('reports a store link it cannot mint a guest session for', async () => {
+ globalThis.fetch = async (url) => {
+ if (String(url).includes('/accounts')) throw new Error('connection reset')
+ throw new Error(`unstubbed fetch: ${url}`)
+ }
+ const result = await gofile.probe('https://store1.gofile.io/download/web/u1/game.zip')
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/could not reach gofile/i)
+ })
+
+ it('reports seed failure as transient when the script is unreachable', async () => {
+ // Fresh install, no stored salt, script host down: nothing validated,
+ // nothing saved, retryable.
+ const dir = useTempStore()
+ globalThis.fetch = async (url) => {
+ if (String(url).includes('/accounts')) return guestAccount()
+ throw new Error('connection reset')
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(result.kind).toBe('transient')
+ expect(result.error).toMatch(/changed something|report/i)
+ expect(fs.existsSync(path.join(dir, 'gofile-salt.json'))).toBe(false)
+ })
+})
+
+const useTempStore = () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-gofile-'))
+ tempDirs.push(dir)
+ process.env.ATLAS_USER_DATA = dir
+ return dir
+}
+
+const storedSalt = (dir) =>
+ JSON.parse(fs.readFileSync(path.join(dir, 'gofile-salt.json'), 'utf8')).salt
+
+// Probe against a contents stub that only accepts one salt, so the wrong
+// salt deterministically yields error-notPremium.
+const stubSaltedListing = (goodSalt, scriptText) => {
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ if (scriptText == null) throw new Error('script must not be fetched')
+ return { ok: true, status: 200, text: async () => scriptText }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ const sent = init?.headers?.['x-website-token']
+ const now = Date.now()
+ const good = [
+ gofile.websiteToken('guest-token', now, goodSalt),
+ gofile.websiteToken('guest-token', now - 14400 * 1000, goodSalt),
+ ]
+ if (good.includes(sent)) return folder({ a: child() })
+ return json({ status: 'error-notPremium' }, 401)
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+}
+
+describe('salt persistence', () => {
+ it('uses the stored salt first without fetching the script', async () => {
+ const dir = useTempStore()
+ const STORED = 'bb22cc33dd44ee'
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: STORED }))
+ stubSaltedListing(STORED, null)
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip')
+ })
+
+ it('replaces the stored salt after a rotation', async () => {
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'oldSalt00000000' }))
+ const NEW_SALT = 'cc33dd44ee55ff'
+ stubSaltedListing(NEW_SALT, `salt="${NEW_SALT}"`)
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(storedSalt(dir)).toBe(NEW_SALT)
+ })
+
+ it('recovers when a bad hash surfaces as rateLimit, not notPremium', async () => {
+ // A bogus seed must self-heal: rateLimit triggers the live refetch too.
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' }))
+ const REAL = 'aa11bb22cc33dd'
+ let scriptFetched = false
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ scriptFetched = true
+ return { ok: true, status: 200, text: async () => `salt="${REAL}"` }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ const sent = init?.headers?.['x-website-token']
+ const now = Date.now()
+ if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() })
+ return json({ status: 'error-rateLimit' }, 429)
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(scriptFetched).toBe(true)
+ expect(result.ok).toBe(true)
+ expect(storedSalt(dir)).toBe(REAL)
+ })
+
+ it('recovers from a bare HTTP 429 with no JSON body', async () => {
+ // Gofile sometimes answers a bad hash with a bodiless 429 rather than a
+ // status string. That must trigger the live refetch, not surface as-is.
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' }))
+ const REAL = 'aa11bb22cc33dd'
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => `salt="${REAL}"` }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ const sent = init?.headers?.['x-website-token']
+ const now = Date.now()
+ if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() })
+ return { ok: false, status: 429, json: async () => null }
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(storedSalt(dir)).toBe(REAL)
+ })
+ it('stops rotation at the first throttle instead of burning budget', async () => {
+ // A throttled candidate means the guest budget is gone: every further
+ // try extends the wall, so the loop ends, nothing is saved, and the
+ // original verdict is returned. Total contents calls: stored + prev +
+ // one candidate.
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' }))
+ const JUNK = 'ff11ee22dd33cc'
+ const REAL = 'aa11bb22cc33dd'
+ let calls = 0
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => `a="${JUNK}" b="${REAL}"` }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ calls += 1
+ const sent = init?.headers?.['x-website-token']
+ const now = Date.now()
+ if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() })
+ if (sent === gofile.websiteToken('guest-token', now, JUNK)) return json({ status: 'error-rateLimit' }, 429)
+ return json({ status: 'error-notPremium' }, 401)
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(calls).toBe(3)
+ expect(storedSalt(dir)).toBe('bogus00000000')
+ })
+
+ it('leaves no stored salt when live recovery fails', async () => {
+ const dir = useTempStore()
+ globalThis.fetch = async (url) => {
+ const text = String(url)
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) return json({ status: 'error-notPremium' }, 401)
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(false)
+ expect(fs.existsSync(path.join(dir, 'gofile-salt.json'))).toBe(false)
+ })
+
+ it('tolerates a corrupt store file', async () => {
+ const dir = useTempStore()
+ fs.writeFileSync(path.join(dir, 'gofile-salt.json'), 'not json{{{')
+ const NEW_SALT = 'dd44ee55ff66gg'
+ stubSaltedListing(NEW_SALT, `salt="${NEW_SALT}"`)
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(storedSalt(dir)).toBe(NEW_SALT)
+ })
+
+ it('spends one listing call per junk candidate, not two', async () => {
+ // Seedless probe: live top seed first (hex-ranked, so the real salt),
+ // then one listing call. The old per-candidate double-window retry
+ // spent two per junk entry.
+ useTempStore()
+ const REAL = 'ee55ff66aa77bb'
+ let listings = 0
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => 'a="junk0000000001" b="junk0000000002" c="ee55ff66aa77bb"' }
+ }
+ if (text.includes('/accounts')) return guestAccount()
+ if (text.includes('/contents/')) {
+ listings += 1
+ const sent = init?.headers?.['x-website-token']
+ const now = Date.now()
+ const good = [
+ gofile.websiteToken('guest-token', now, REAL),
+ gofile.websiteToken('guest-token', now - 14400 * 1000, REAL),
+ ]
+ if (good.includes(sent)) return folder({ a: child() })
+ return json({ status: 'error-notPremium' }, 401)
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ // Seed fetch (script, uncounted) + one listing with the top seed.
+ // No second-window pass: the hit landed on the current window.
+ expect(listings).toBe(1)
+ })
+
+ it('mints a fresh token when the cached one is rejected', async () => {
+ // Session token cache: a wrongToken verdict (IP change, expiry) drops
+ // it, mints once, and retries — then succeeds instead of staying dead.
+ const dir = useTempStore()
+ gofile.saltStore.resetGuest()
+ const GOOD = 'aa11bb22cc33dd'
+ let accounts = 0
+ globalThis.fetch = async (url, init) => {
+ const text = String(url)
+ if (text.includes('wt.obf.js')) {
+ return { ok: true, status: 200, text: async () => `salt="${GOOD}"` }
+ }
+ if (text.includes('/accounts')) {
+ accounts += 1
+ return json({ status: 'ok', data: { token: accounts === 1 ? 'stale-1' : 'fresh-2', id: 'account-1' } })
+ }
+ if (text.includes('/contents/')) {
+ // Rotation point is the token, not the hash (covered elsewhere).
+ if (init?.headers?.authorization === 'Bearer fresh-2') return folder({ a: child() })
+ return json({ status: 'error-wrongToken' }, 401)
+ }
+ throw new Error(`unstubbed fetch: ${text}`)
+ }
+ const result = await gofile.probe('https://gofile.io/d/AbCdEfGh')
+ expect(result.ok).toBe(true)
+ expect(accounts).toBe(2)
+ expect(storedSalt(dir)).toBe(GOOD)
+ })
+
+ it('shares one script fetch across concurrent refreshes', async () => {
+ const saltStore = gofile.saltStore
+ let scripts = 0
+ globalThis.fetch = async () => {
+ scripts += 1
+ return { ok: true, status: 200, text: async () => 'salt="ee55ff66aa77bb"' }
+ }
+ const [a, b] = await Promise.all([saltStore.refresh(), saltStore.refresh()])
+ expect(scripts).toBe(1)
+ expect(a).toEqual(b)
+ })
+})
+
+describe('validate', () => {
+ it('is always anonymous', async () => {
+ expect(await gofile.validate({})).toEqual({ ok: true, anonymous: true })
+ })
+})
+
+describe('getQuota', () => {
+ const details = (data) => json({ status: 'ok', data: { id: 'account-1', tier: 'guest', ...data } })
+ const quotaRoutes = (detailResp) => [
+ ['/accounts/account-1', detailResp],
+ ['/accounts', guestAccount()],
+ ]
+
+ it('sums recent usage against the free allowance', async () => {
+ const d = new Date()
+ stubFetch(quotaRoutes(details({ ipTraffic: {
+ 2020: { 1: { 1: 999 } },
+ [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: 100 } },
+ } })))
+ const quota = await gofile.getQuota()
+ expect(quota.ok).toBe(true)
+ expect(quota.used).toBe(100)
+ expect(quota.cap).toBe(1000000000000)
+ })
+
+ it('reports no cap for non-guest tiers', async () => {
+ stubFetch(quotaRoutes(details({ tier: 'premium', ipTraffic: {} })))
+ const quota = await gofile.getQuota()
+ expect(quota.ok).toBe(true)
+ expect(quota.used).toBe(0)
+ expect(quota.cap).toBeNull()
+ })
+
+ it('fails instead of guessing when usage is missing', async () => {
+ stubFetch(quotaRoutes(details({})))
+ expect((await gofile.getQuota()).ok).toBe(false)
+ })
+
+ it('reads string usage buckets instead of reporting zero', async () => {
+ const d = new Date()
+ stubFetch(quotaRoutes(details({ ipTraffic: {
+ [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: '100' } },
+ } })))
+ const quota = await gofile.getQuota()
+ expect(quota.ok).toBe(true)
+ expect(quota.used).toBe(100)
+ })
+
+ it('sums nested hourly leaves instead of skipping them', async () => {
+ const d = new Date()
+ stubFetch(quotaRoutes(details({ ipTraffic: {
+ [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: { 0: 40, 12: 60 } } },
+ } })))
+ const quota = await gofile.getQuota()
+ expect(quota.ok).toBe(true)
+ expect(quota.used).toBe(100)
+ })
+})
diff --git a/tests/update-modal-options.test.jsx b/tests/update-modal-options.test.jsx
index 500eb192..b0245885 100644
--- a/tests/update-modal-options.test.jsx
+++ b/tests/update-modal-options.test.jsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { render, screen, cleanup, waitFor } from '@testing-library/react'
+import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'
import UpdateModal from '../src/components/downloads/UpdateModal.jsx'
@@ -101,4 +101,107 @@ describe('UpdateModal build options', () => {
mount([link('mega.nz', '', 'Win'), link('mega.nz', 'Season 1', 'Win')])
await waitFor(() => { expect(screen.getByText('Full Archive')).toBeTruthy() })
})
+
+ it('expands a multi-file gofile folder into a picker and queues the picked file', async () => {
+ mount([link('gofile.io', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io',
+ })
+ window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({
+ ok: true,
+ choices: [
+ { name: 'part1.zip', size: 10, directUrl: 'https://store1.gofile.io/1' },
+ { name: 'part2.zip', size: 20, directUrl: 'https://store1.gofile.io/2' },
+ ],
+ })
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() })
+ fireEvent.click(screen.getByText('gofile.io'))
+ await waitFor(() => { expect(screen.getByText('part1.zip')).toBeTruthy() })
+ expect(screen.getByText('10 B')).toBeTruthy()
+ fireEvent.click(screen.getByText('part2.zip'))
+ await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() })
+ expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://store1.gofile.io/2')
+ })
+
+ it('queues a direct file link when no plugin claims it instead of refusing', async () => {
+ mount([link('gofile.io', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://store1.gofile.io/download/web/u1/game.zip', host: 'store1.gofile.io',
+ })
+ window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({
+ ok: false, error: 'No plugin for this host',
+ })
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() })
+ fireEvent.click(screen.getByText('gofile.io'))
+ await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() })
+ expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://store1.gofile.io/download/web/u1/game.zip')
+ })
+
+ it('shows the picker for any host whose listing returns choices', async () => {
+ mount([link('buzzheavier.com', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://buzzheavier.com/f/AbCd12', host: 'buzzheavier.com',
+ })
+ window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({
+ ok: true,
+ choices: [
+ { name: 'a.zip', size: 1, directUrl: 'https://buzzheavier.com/d/a' },
+ ],
+ })
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('buzzheavier.com')).toBeTruthy() })
+ fireEvent.click(screen.getByText('buzzheavier.com'))
+ await waitFor(() => { expect(screen.getByText('a.zip')).toBeTruthy() })
+ expect(window.electronAPI.downloadsEnqueue).not.toHaveBeenCalled()
+ })
+
+ it('shows a failed folder listing instead of queueing blindly', async () => {
+ mount([link('gofile.io', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io',
+ })
+ window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({
+ ok: false, error: 'Gofile returned error-teapot',
+ })
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() })
+ fireEvent.click(screen.getByText('gofile.io'))
+ await waitFor(() => { expect(screen.getByText('Gofile returned error-teapot')).toBeTruthy() })
+ expect(window.electronAPI.downloadsEnqueue).not.toHaveBeenCalled()
+ })
+
+ it('queues a single-file listing directly with no picker', async () => {
+ mount([link('gofile.io', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io',
+ })
+ window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({
+ ok: true, directUrl: 'https://store1.gofile.io/download/web/u1/game.zip',
+ fileName: 'game.zip', fileSize: 42,
+ })
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() })
+ fireEvent.click(screen.getByText('gofile.io'))
+ await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() })
+ // The share URL, never the listing: no second probe, no picker.
+ expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url)
+ .toBe('https://store1.gofile.io/download/web/u1/game.zip')
+ })
+
+ it('falls back to the resolved url when the listing api is missing', async () => {
+ mount([link('gofile.io', 'Season 2', 'Win')])
+ window.electronAPI.downloadsResolveMasked.mockResolvedValue({
+ ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io',
+ })
+ // Older preload without downloadsListFolder: optional call short-circuits
+ // to undefined instead of throwing into the error path.
+ delete window.electronAPI.downloadsListFolder
+ window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} })
+ await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() })
+ fireEvent.click(screen.getByText('gofile.io'))
+ await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() })
+ expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://gofile.io/d/AbCdEfGh')
+ })
})
From 60365c1f042041bdad626e4113da2b275c256463 Mon Sep 17 00:00:00 2001
From: Codeon <313085171+codeon89@users.noreply.github.com>
Date: Fri, 11 Sep 2026 01:25:09 -0700
Subject: [PATCH 2/2] PATCHED: CHANGELOG #408
---
CHANGELOG.PATCHED.md | 3 +++
CHANGELOG.md | 1 -
PATCHES.md | 1 +
3 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.PATCHED.md b/CHANGELOG.PATCHED.md
index 227bff2f..0e180ac7 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/CHANGELOG.md b/CHANGELOG.md
index fb812b60..0b7a4439 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,6 @@
- Path resolution highlighting: red if invalid, green if path exists or pass the check.
### Added
-- 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.
- Custom media uploads in the Game Details Media tab: add preview images from local files, a drag-and-drop zone, or an image URL, with live progress. Previews can be reordered by drag and the order persists in a new `preview_sort` table keyed by remote URL (or relative path for custom uploads), so it survives re-downloads, stream/download switches and metadata refreshes. Previews now carry a source logo and a storage-location badge, and custom previews can be deleted independently of downloaded ones.
- Add Buzzheavier host support (`buzzheavier.com`, `bzzhr.to`, `bzzhr.co`). The download route is behind a Cloudflare challenge, so the resolve runs in a browser window: it clicks the htmx download button, captures the `HX-Redirect` (or an attachment via `will-download`), and hands the resolved CDN link plus the browser's own cookies/UA back to the downloader. The resolve partition is persistent so a solved challenge survives a restart -- only Cloudflare's own challenge cookies are kept, everything else is stripped after each resolve -- and resolves run one at a time, since a shared session cannot carry two concurrently. Each time your IP changes there is a brief auto-resolve window while the challenge is re-solved.
- The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v` for stable and `v-nightly.` for nightly -- so it lands on the real release rather than a 404. (#143)
diff --git a/PATCHES.md b/PATCHES.md
index 9ec90c76..0fe60c51 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)