mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 23:48:45 -07:00
feat(site): Downloads page and Quest install from the latest build
When the server is pointed at the bucket CI publishes builds to (DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and the Quest page offers "Latest build" next to "Choose file". The server reads manifest.json and the files over HTTP on the internal network and streams them through with Range support (DownloadsController); only files the manifest lists are served. The xapk reader now reads from a byte source, either the picked File or the server URL via Range requests, so the browser installer streams the APK and OBB to the headset without downloading the package first. Unset, both features stay hidden.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// The client builds CI publishes (ci/downloads/publish.py), served through
|
||||
// the server's DownloadsController. The manifest keeps the snake_case keys
|
||||
// publish.py writes.
|
||||
import { ApiError, get } from './api';
|
||||
|
||||
export interface DownloadFile {
|
||||
id: string; // pc-full | pc-patch | quest | quest-installer
|
||||
platform: 'pc' | 'quest' | string;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
build?: string;
|
||||
commit?: string;
|
||||
built_at?: string;
|
||||
}
|
||||
|
||||
export interface DownloadRelease {
|
||||
id: string;
|
||||
title: string;
|
||||
game_version?: string;
|
||||
pc_exe?: string;
|
||||
files: DownloadFile[];
|
||||
}
|
||||
|
||||
export interface DownloadsManifest {
|
||||
releases: DownloadRelease[];
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
let cached: Promise<DownloadsManifest | null> | null = null;
|
||||
|
||||
/** The manifest, or null when this server doesn't offer downloads. Fetched
|
||||
* once per page load; pass `fresh` to re-read it. */
|
||||
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
||||
if (!cached || fresh) {
|
||||
cached = get<DownloadsManifest>('/downloads').catch((e) => {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
cached = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const fileUrl = (f: DownloadFile) =>
|
||||
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
|
||||
|
||||
/** The newest release's Quest package, if one is published. */
|
||||
export function latestQuest(m: DownloadsManifest | null): { release: DownloadRelease; file: DownloadFile } | null {
|
||||
for (const release of m?.releases ?? []) {
|
||||
const file = release.files.find((f) => f.id === 'quest');
|
||||
if (file) return { release, file };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export async function installXapk(
|
||||
let done = 0;
|
||||
const total = xapk.apk.size;
|
||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||
const stream = counted(await entryStream(xapk.file, xapk.apk), (n) => {
|
||||
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
|
||||
done += n;
|
||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||
});
|
||||
@@ -124,7 +124,7 @@ export async function installXapk(
|
||||
|
||||
let done = 0;
|
||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||
const stream = counted(await entryStream(xapk.file, entry), (n) => {
|
||||
const stream = counted(await entryStream(xapk.source, entry), (n) => {
|
||||
done += n;
|
||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||
});
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
|
||||
// loading it into memory. DorkNet.Quest/scripts/build.py stores the APK and
|
||||
// the OBB uncompressed, so those entries are plain byte ranges of the file and
|
||||
// stream out with Blob.slice(); manifest.json is deflated and goes through
|
||||
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
|
||||
// File the player picked or from a URL that answers HTTP Range requests (the
|
||||
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
|
||||
// APK and the OBB uncompressed, so those entries are plain byte ranges that
|
||||
// stream straight through; manifest.json is deflated and goes through
|
||||
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
||||
|
||||
/** Random access to the package's bytes; `end` is exclusive. */
|
||||
export interface ByteSource {
|
||||
name: string;
|
||||
size: number;
|
||||
read(start: number, end: number): Promise<ArrayBuffer>;
|
||||
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
|
||||
}
|
||||
|
||||
export function fileSource(file: File): ByteSource {
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
read: (start, end) => file.slice(start, end).arrayBuffer(),
|
||||
stream: async (start, end) => file.slice(start, end).stream(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A package on the server. `size` comes from the downloads manifest; every
|
||||
* read is one Range request. */
|
||||
export function urlSource(url: string, name: string, size: number): ByteSource {
|
||||
const get = async (start: number, end: number): Promise<Response> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
|
||||
} catch {
|
||||
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
|
||||
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
throw new Error(res.status === 200
|
||||
? "The server doesn't support partial downloads, so the package can't be read in place."
|
||||
: `Downloading the package failed (HTTP ${res.status}).`);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
|
||||
stream: async (start, end) => {
|
||||
if (end <= start) return empty();
|
||||
const body = (await get(start, end)).body;
|
||||
if (!body) throw new Error('The server sent an empty response.');
|
||||
return body;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface XapkEntry {
|
||||
name: string;
|
||||
method: number; // 0 = stored, 8 = deflate
|
||||
@@ -26,7 +76,7 @@ export interface XapkManifest {
|
||||
}
|
||||
|
||||
export interface Xapk {
|
||||
file: File;
|
||||
source: ByteSource;
|
||||
manifest: XapkManifest;
|
||||
entries: Map<string, XapkEntry>;
|
||||
apk: XapkEntry;
|
||||
@@ -39,18 +89,18 @@ const ZIP64_EOCD = 0x06064b50;
|
||||
const CENTRAL = 0x02014b50;
|
||||
const LOCAL = 0x04034b50;
|
||||
|
||||
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
|
||||
return new DataView(await file.slice(start, end).arrayBuffer());
|
||||
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
|
||||
return new DataView(await src.read(start, end));
|
||||
}
|
||||
|
||||
function u64(v: DataView, at: number): number {
|
||||
return Number(v.getBigUint64(at, true));
|
||||
}
|
||||
|
||||
export async function openXapk(file: File): Promise<Xapk> {
|
||||
export async function openXapk(src: ByteSource): Promise<Xapk> {
|
||||
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
|
||||
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
|
||||
const tail = await bytes(file, tailStart, file.size);
|
||||
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
|
||||
const tail = await bytes(src, tailStart, src.size);
|
||||
let eocd = -1;
|
||||
for (let i = tail.byteLength - 22; i >= 0; i--) {
|
||||
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
|
||||
@@ -65,14 +115,14 @@ export async function openXapk(file: File): Promise<Xapk> {
|
||||
const loc = eocd - 20;
|
||||
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
|
||||
const z64At = u64(tail, loc + 8);
|
||||
const z64 = await bytes(file, z64At, z64At + 56);
|
||||
const z64 = await bytes(src, z64At, z64At + 56);
|
||||
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
|
||||
count = u64(z64, 32);
|
||||
cdSize = u64(z64, 40);
|
||||
cdOffset = u64(z64, 48);
|
||||
}
|
||||
|
||||
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
|
||||
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
|
||||
const entries = new Map<string, XapkEntry>();
|
||||
const decoder = new TextDecoder();
|
||||
let p = 0;
|
||||
@@ -109,7 +159,7 @@ export async function openXapk(file: File): Promise<Xapk> {
|
||||
|
||||
const manifestEntry = entries.get('manifest.json');
|
||||
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
|
||||
const manifest = JSON.parse(await new Response(await entryStream(file, manifestEntry)).text()) as XapkManifest;
|
||||
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
|
||||
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
|
||||
|
||||
const apk = entries.get(`${manifest.package_name}.apk`)
|
||||
@@ -122,15 +172,15 @@ export async function openXapk(file: File): Promise<Xapk> {
|
||||
return { entry, installPath: x.install_path };
|
||||
});
|
||||
|
||||
return { file, manifest, entries, apk, expansions };
|
||||
return { source: src, manifest, entries, apk, expansions };
|
||||
}
|
||||
|
||||
/** The entry's uncompressed bytes as a stream. */
|
||||
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
|
||||
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
||||
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
|
||||
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
||||
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
|
||||
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
|
||||
const raw = file.slice(start, start + entry.compressedSize).stream();
|
||||
const raw = await src.stream(start, start + entry.compressedSize);
|
||||
if (entry.method === 0) return raw;
|
||||
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
throw new Error(`Unsupported compression in ${entry.name}.`);
|
||||
|
||||
Reference in New Issue
Block a user