Support resumable game folder uploads and remove DLC declarations

This commit is contained in:
2026-10-04 09:51:45 -04:00
parent 95cf7dd36f
commit 840f02b53e
22 changed files with 1121 additions and 102 deletions
@@ -30,7 +30,7 @@
{#if !loading && !hosts.some(candidate => candidate.readiness === 'ready')}<p>No deployment-ready hosts. <a href="/hosts" target="_blank" rel="noopener">Set up a host</a>, then refresh.</p>{/if}
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id} disabled={!cacheReady(version.id)}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Not cached on host'}</option>{/each}</select></label>{/each}
<p><a href="/game-files" target="_blank" rel="noopener">Import or download game files to a host</a>, then refresh. Each launch stays pinned to its selected library version.</p>
<p><a href="/game-files" target="_blank" rel="noopener">Upload a game folder or download a version to a host</a>, then refresh. Each launch stays pinned to its selected library version.</p>
<label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label>
{#if host && !imageReady}<p class="error">Prepare this runtime image from <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">host setup</a> before launch.</p>{/if}
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
+38
View File
@@ -0,0 +1,38 @@
export interface SelectedGameFile { path: string; file: File }
export interface SelectedGameFolder { name: string; files: SelectedGameFile[]; size: number }
/** Directory inputs include the selected folder in every relative path. Remove
* that one component and retain the complete game directory below it. */
export function selectGameFolder(selection: Iterable<File>): SelectedGameFolder {
const files: SelectedGameFile[] = [];
const paths = new Set<string>();
let name = '';
let size = 0;
for (const file of selection) {
const [root, ...parts] = file.webkitRelativePath.split('/');
if (!root || root === '.' || root === '..' || !parts.length || name && root !== name) throw new Error('Choose one game folder with the folder picker.');
name = root;
const path = parts.join('/');
if (path.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(path) || parts.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) {
throw new Error(`This file has an unsupported game path: ${file.webkitRelativePath}`);
}
const key = path.toLowerCase();
if (paths.has(key)) throw new Error(`This folder has duplicate game paths: ${path}`);
paths.add(key);
size += file.size;
if (!Number.isSafeInteger(size)) throw new Error('This folder exceeds the supported upload size.');
files.push({ path, file });
if (files.length > 200_000) throw new Error('Choose a folder with no more than 200,000 files.');
}
if (!files.length) throw new Error('The selected folder contains no files.');
for (const { path } of files) {
const parents = path.toLowerCase().split('/');
parents.pop();
while (parents.length) {
if (paths.has(parents.join('/'))) throw new Error(`This folder uses the same game path for a file and a directory: ${path}`);
parents.pop();
}
}
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
return { name, files, size };
}