feat: install the Quest build from the website

A Quest page on the public site (/quest) installs a DorkNet .xapk from
Chrome or Edge over WebUSB, with no adb, SideQuest or scripts: pick the
.xapk, connect the headset, install. It installs the APK, copies the OBB
to its install path (skipped when an identical one is already there) and
launches the app, with progress bars and plain-language errors for an
unsupported browser, a headset held by adb/SideQuest, a disconnect and a
copy signed with a different key.

The .xapk is read from the player's own disk: the APK and OBB are stored
uncompressed, so they stream straight out of the file without loading
1.4 GB into memory, and the server never hosts game files.

Uses Tango (@yume-chan/adb 2.6.4 and friends, MIT), pinned and bundled;
the page is lazy-loaded so the rest of the site doesn't grow. Credited in
NOTICE; README, DorkNet.Quest/README.md, docs/quest.md and the packaged
README.txt point players at the page.
This commit is contained in:
Alexa
2026-10-15 22:34:25 +03:00
parent 6272844445
commit a7879d7ede
12 changed files with 586 additions and 8 deletions
+142
View File
@@ -0,0 +1,142 @@
// Install a DorkNet .xapk onto a Meta Quest from the browser over WebUSB, using
// Tango (@yume-chan/adb). Mirrors DorkNet.Quest/scripts/installer/install.ps1:
// install the APK, copy each OBB to its install path (skipped when an identical
// size is already there), then launch.
import { Adb, AdbDaemonTransport } from '@yume-chan/adb';
import { AdbDaemonWebUsbDevice, AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { PackageManager } from '@yume-chan/android-bin';
import type { ReadableStream as TangoReadableStream } from '@yume-chan/stream-extra';
import { entryStream, type Xapk } from './xapk';
export const webUsbSupported = () => typeof navigator !== 'undefined' && 'usb' in navigator && !!AdbDaemonWebUsbDeviceManager.BROWSER;
export interface Headset {
adb: Adb;
model: string;
serial: string;
}
export type Phase = 'app' | 'data' | 'launch';
export interface Progress {
phase: Phase;
label: string;
done: number;
total: number;
}
const credentialStore = new AdbWebCredentialStore('DorkNet Quest installer');
export async function connectHeadset(): Promise<Headset> {
const manager = AdbDaemonWebUsbDeviceManager.BROWSER;
if (!manager) throw new Error('This browser does not support WebUSB. Use Chrome or Edge on a computer.');
const device = await manager.requestDevice();
if (!device) throw new Error('No headset selected.');
let connection;
try {
connection = await device.connect();
} catch (e) {
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
throw new Error('The headset is in use by another program. Close SideQuest, Meta Quest Developer Hub or adb (run "adb kill-server"), then try again.');
}
throw e;
}
const transport = await AdbDaemonTransport.authenticate({
serial: device.serial,
connection,
credentialStore,
});
const adb = new Adb(transport);
const model = (await adb.getProp('ro.product.model')).trim() || device.name;
return { adb, model, serial: device.serial };
}
function counted(stream: ReadableStream<Uint8Array>, onBytes: (n: number) => void): TangoReadableStream<Uint8Array> {
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
onBytes(chunk.byteLength);
controller.enqueue(chunk);
},
});
return stream.pipeThrough(counter) as unknown as TangoReadableStream<Uint8Array>;
}
async function remoteSize(adb: Adb, path: string): Promise<number | null> {
const sync = await adb.sync();
try {
const st = await sync.lstat(path);
return Number(st.size);
} catch {
return null;
} finally {
await sync.dispose();
}
}
/**
* Install the package. `confirmUninstall` is asked before removing an existing
* copy signed with a different key (that clears its local data).
*/
export async function installXapk(
headset: Headset,
xapk: Xapk,
onProgress: (p: Progress) => void,
confirmUninstall: (pkg: string) => Promise<boolean>,
): Promise<void> {
const { adb } = headset;
const pkg = xapk.manifest.package_name;
const pm = new PackageManager(adb);
// ---- APK ----
const installApk = async () => {
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) => {
done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
await pm.installStream(total, stream);
};
try {
await installApk();
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(msg)) throw e;
if (!(await confirmUninstall(pkg))) throw new Error('Cancelled.');
await pm.uninstall(pkg);
await installApk();
}
// ---- game data (OBB) ----
for (const { entry, installPath } of xapk.expansions) {
const dest = `/sdcard/${installPath}`;
const total = entry.size;
if ((await remoteSize(adb, dest)) === total) {
onProgress({ phase: 'data', label: 'Game data already on the headset', done: total, total });
continue;
}
const dir = dest.substring(0, dest.lastIndexOf('/'));
await adb.subprocess.noneProtocol.spawnWaitText(['mkdir', '-p', dir]);
let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.file, entry), (n) => {
done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
});
const sync = await adb.sync();
try {
await sync.write({ filename: dest, file: stream, permission: 0o660 });
} finally {
await sync.dispose();
}
}
// ---- launch ----
onProgress({ phase: 'launch', label: 'Starting the game', done: 1, total: 1 });
await adb.subprocess.noneProtocol.spawnWaitText(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
}
+137
View File
@@ -0,0 +1,137 @@
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
// loading it into memory. DorkNet.Quest/scripts/package.ps1 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
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
export interface XapkEntry {
name: string;
method: number; // 0 = stored, 8 = deflate
compressedSize: number;
size: number;
localHeaderOffset: number;
}
export interface XapkExpansion {
file: string;
install_path: string;
}
export interface XapkManifest {
package_name: string;
name?: string;
version_code?: string;
version_name?: string;
expansions?: XapkExpansion[];
}
export interface Xapk {
file: File;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
expansions: { entry: XapkEntry; installPath: string }[];
}
const EOCD = 0x06054b50;
const ZIP64_LOCATOR = 0x07064b50;
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());
}
function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true));
}
export async function openXapk(file: File): 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);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
}
if (eocd < 0) throw new Error('This is not a zip/.xapk file.');
let count = tail.getUint16(eocd + 10, true);
let cdSize = tail.getUint32(eocd + 12, true);
let cdOffset = tail.getUint32(eocd + 16, true);
if (cdOffset === 0xffffffff || count === 0xffff || cdSize === 0xffffffff) {
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);
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 entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder();
let p = 0;
for (let n = 0; n < count; n++) {
if (cd.getUint32(p, true) !== CENTRAL) throw new Error('Corrupt zip directory.');
const method = cd.getUint16(p + 10, true);
let compressedSize = cd.getUint32(p + 20, true);
let size = cd.getUint32(p + 24, true);
const nameLen = cd.getUint16(p + 28, true);
const extraLen = cd.getUint16(p + 30, true);
const commentLen = cd.getUint16(p + 32, true);
let localHeaderOffset = cd.getUint32(p + 42, true);
const name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + p + 46, nameLen));
// zip64 extended info: present fields appear in this order, only for
// values saturated in the fixed header.
let e = p + 46 + nameLen;
const extraEnd = e + extraLen;
while (e + 4 <= extraEnd) {
const id = cd.getUint16(e, true);
const len = cd.getUint16(e + 2, true);
if (id === 0x0001) {
let q = e + 4;
if (size === 0xffffffff) { size = u64(cd, q); q += 8; }
if (compressedSize === 0xffffffff) { compressedSize = u64(cd, q); q += 8; }
if (localHeaderOffset === 0xffffffff) { localHeaderOffset = u64(cd, q); }
}
e += 4 + len;
}
entries.set(name, { name, method, compressedSize, size, localHeaderOffset });
p += 46 + nameLen + extraLen + commentLen;
}
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;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`)
?? [...entries.values()].find((x) => x.name.endsWith('.apk'));
if (!apk) throw new Error('The .xapk contains no APK.');
const expansions = (manifest.expansions ?? []).map((x) => {
const entry = entries.get(x.file);
if (!entry) throw new Error(`The .xapk is missing ${x.file}.`);
return { entry, installPath: x.install_path };
});
return { file, 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);
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();
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}.`);
}