From a7879d7edea0805b787c6a3b5b5d062268dfdc1a Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:34:25 +0300 Subject: [PATCH] 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. --- DorkNet.Quest/README.md | 13 +- DorkNet.Quest/scripts/package.ps1 | 4 +- DorkNet.Server/site/package-lock.json | 98 +++++++++++ DorkNet.Server/site/package.json | 5 + DorkNet.Server/site/src/App.tsx | 5 + DorkNet.Server/site/src/components/Header.tsx | 1 + DorkNet.Server/site/src/lib/questInstall.ts | 142 +++++++++++++++ DorkNet.Server/site/src/lib/xapk.ts | 137 +++++++++++++++ DorkNet.Server/site/src/pages/Quest.tsx | 166 ++++++++++++++++++ NOTICE | 5 + README.md | 11 +- docs/quest.md | 7 +- 12 files changed, 586 insertions(+), 8 deletions(-) create mode 100644 DorkNet.Server/site/src/lib/questInstall.ts create mode 100644 DorkNet.Server/site/src/lib/xapk.ts create mode 100644 DorkNet.Server/site/src/pages/Quest.tsx diff --git a/DorkNet.Quest/README.md b/DorkNet.Quest/README.md index 86f7dd4..bc4e639 100644 --- a/DorkNet.Quest/README.md +++ b/DorkNet.Quest/README.md @@ -129,7 +129,18 @@ the running `libil2cpp.so`, so a table can never be applied to the wrong build. ## Install -From a release folder, one command does everything (finds adb and the headset, +**From the browser (easiest).** Open the **Quest** page of your DorkNet site +(`https:///quest`) in Chrome or Edge on a computer, plug the headset in +over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in +the headset, then **Install**. The page talks to the headset over WebUSB +(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install +path (skipped if an identical one is already there) and launches the app. The +`.xapk` never leaves the player's computer; the server hosts no game files. +Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first: +only one program can hold the headset's USB connection. Firefox, Safari and +phones don't support WebUSB. + +**From a release folder**, one command does everything (finds adb and the headset, installs the APK, copies the OBB to the right path, skips the OBB if it is already there, and launches the app): diff --git a/DorkNet.Quest/scripts/package.ps1 b/DorkNet.Quest/scripts/package.ps1 index 4871ae3..decb13e 100644 --- a/DorkNet.Quest/scripts/package.ps1 +++ b/DorkNet.Quest/scripts/package.ps1 @@ -150,7 +150,9 @@ $($manifest.name) $verName - Rec Room for DorkNet (Meta Quest) 1. Turn on developer mode for your headset (Meta Horizon phone app), plug it in over USB and accept "Allow USB debugging" inside the headset. -2. Install: +2. Install. Easiest: open the Quest page of your DorkNet server's website + (https:///quest) in Chrome or Edge, pick this .xapk and follow the + steps. Or use the scripts here: Windows: double-click install.cmd macOS / Linux: ./install.sh The installer needs adb (Android platform-tools) on PATH or in diff --git a/DorkNet.Server/site/package-lock.json b/DorkNet.Server/site/package-lock.json index 4c8fe25..b099002 100644 --- a/DorkNet.Server/site/package-lock.json +++ b/DorkNet.Server/site/package-lock.json @@ -8,6 +8,11 @@ "name": "dorknet-site", "version": "0.1.0", "dependencies": { + "@yume-chan/adb": "2.6.4", + "@yume-chan/adb-credential-web": "2.1.0", + "@yume-chan/adb-daemon-webusb": "2.3.2", + "@yume-chan/android-bin": "2.1.0", + "@yume-chan/stream-extra": "2.6.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^8.3.0" @@ -1350,6 +1355,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1371,6 +1382,93 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@yume-chan/adb": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-2.6.4.tgz", + "integrity": "sha512-aaN21ZOLjWW1nC09C21f0yQkCqxkuMWMqheryGEbmGLPhuzIyOhTurJK8cXh5ZnQ+ptdKBexqvoG6zYoul9XPA==", + "license": "MIT", + "dependencies": { + "@yume-chan/async": "^4.1.3", + "@yume-chan/event": "^2.0.0", + "@yume-chan/no-data-view": "^2.0.0", + "@yume-chan/stream-extra": "^2.6.1", + "@yume-chan/struct": "^2.3.2" + } + }, + "node_modules/@yume-chan/adb-credential-web": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-2.1.0.tgz", + "integrity": "sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==", + "license": "MIT", + "dependencies": { + "@yume-chan/adb": "^2.1.0" + } + }, + "node_modules/@yume-chan/adb-daemon-webusb": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-2.3.2.tgz", + "integrity": "sha512-5ANaqsRJWFc5kKMnGALjYCrSXo5eC4u51dvYppvuBCLVjv12n7he2TPy5yEZ3XtlToRFS0kWCQC0XS8M60lRSA==", + "license": "MIT", + "dependencies": { + "@types/w3c-web-usb": "^1.0.12", + "@yume-chan/adb": "^2.3.1", + "@yume-chan/event": "^2.0.0", + "@yume-chan/stream-extra": "^2.1.0", + "@yume-chan/struct": "^2.3.2" + } + }, + "node_modules/@yume-chan/android-bin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-2.1.0.tgz", + "integrity": "sha512-is97IQwmSTmGCLe8GKO3rRDib2is6Xumo9KzeArgHwizIF0XnpCUm/xIVJLnpifR9PlMyLldDUttTZMTnaNivg==", + "license": "MIT", + "dependencies": { + "@yume-chan/adb": "^2.1.0", + "@yume-chan/stream-extra": "^2.1.0", + "@yume-chan/struct": "^2.0.1" + } + }, + "node_modules/@yume-chan/async": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.1.3.tgz", + "integrity": "sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==", + "license": "MIT" + }, + "node_modules/@yume-chan/event": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-2.0.0.tgz", + "integrity": "sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==", + "license": "MIT", + "dependencies": { + "@yume-chan/async": "^4.0.2" + } + }, + "node_modules/@yume-chan/no-data-view": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-2.0.0.tgz", + "integrity": "sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==", + "license": "MIT" + }, + "node_modules/@yume-chan/stream-extra": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-2.6.1.tgz", + "integrity": "sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==", + "license": "MIT", + "dependencies": { + "@yume-chan/async": "^4.1.3", + "@yume-chan/struct": "^2.3.2" + } + }, + "node_modules/@yume-chan/struct": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-2.3.2.tgz", + "integrity": "sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==", + "license": "MIT", + "dependencies": { + "@yume-chan/async": "^4.1.3", + "@yume-chan/no-data-view": "^2.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", diff --git a/DorkNet.Server/site/package.json b/DorkNet.Server/site/package.json index 33e962b..b458417 100644 --- a/DorkNet.Server/site/package.json +++ b/DorkNet.Server/site/package.json @@ -13,6 +13,11 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@yume-chan/adb": "2.6.4", + "@yume-chan/adb-credential-web": "2.1.0", + "@yume-chan/adb-daemon-webusb": "2.3.2", + "@yume-chan/android-bin": "2.1.0", + "@yume-chan/stream-extra": "2.6.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^8.3.0" diff --git a/DorkNet.Server/site/src/App.tsx b/DorkNet.Server/site/src/App.tsx index d61c1ce..7978f7b 100644 --- a/DorkNet.Server/site/src/App.tsx +++ b/DorkNet.Server/site/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router'; import { Header } from './components/Header'; import { Home } from './pages/Home'; @@ -9,6 +10,9 @@ import { Rooms } from './pages/Rooms'; import { Join } from './pages/Join'; import { NotFound } from './pages/NotFound'; +// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest. +const Quest = lazy(() => import('./pages/Quest').then((m) => ({ default: m.Quest }))); + export function App() { return (
@@ -22,6 +26,7 @@ export function App() { } /> } /> } /> + } /> {/* Legacy paths from the vanilla-JS feed.localhost site */} } /> } /> diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index 2b94895..57735ff 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -8,6 +8,7 @@ const NAV = [ { to: '/players', label: 'Players' }, { to: '/rooms', label: 'Rooms' }, { to: '/join', label: 'Join' }, + { to: '/quest', label: 'Quest' }, ]; export function Header() { diff --git a/DorkNet.Server/site/src/lib/questInstall.ts b/DorkNet.Server/site/src/lib/questInstall.ts new file mode 100644 index 0000000..f29fb7c --- /dev/null +++ b/DorkNet.Server/site/src/lib/questInstall.ts @@ -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 { + 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, onBytes: (n: number) => void): TangoReadableStream { + const counter = new TransformStream({ + transform(chunk, controller) { + onBytes(chunk.byteLength); + controller.enqueue(chunk); + }, + }); + return stream.pipeThrough(counter) as unknown as TangoReadableStream; +} + +async function remoteSize(adb: Adb, path: string): Promise { + 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, +): Promise { + 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']); +} diff --git a/DorkNet.Server/site/src/lib/xapk.ts b/DorkNet.Server/site/src/lib/xapk.ts new file mode 100644 index 0000000..27c0b1b --- /dev/null +++ b/DorkNet.Server/site/src/lib/xapk.ts @@ -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; + 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 { + 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 { + // 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(); + 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> { + 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}.`); +} diff --git a/DorkNet.Server/site/src/pages/Quest.tsx b/DorkNet.Server/site/src/pages/Quest.tsx new file mode 100644 index 0000000..4775dfd --- /dev/null +++ b/DorkNet.Server/site/src/pages/Quest.tsx @@ -0,0 +1,166 @@ +import { useRef, useState } from 'react'; +import { openXapk, type Xapk } from '../lib/xapk'; +import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall'; + +const fmtSize = (n: number) => + n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; + +function Step({ n, title, done, children }: { n: number; title: string; done?: boolean; children: React.ReactNode }) { + return ( +
+

+ + {done ? '✓' : n} + + {title} +

+ {children} +
+ ); +} + +function Bar({ p }: { p: Progress }) { + const pct = p.total > 0 ? Math.min(100, Math.round((p.done / p.total) * 100)) : 0; + return ( +
+
+ {p.label} + {p.phase === 'launch' ? '' : `${fmtSize(p.done)} / ${fmtSize(p.total)} · ${pct}%`} +
+
+
+
+
+ ); +} + +export function Quest() { + const supported = webUsbSupported(); + const [xapk, setXapk] = useState(null); + const [headset, setHeadset] = useState(null); + const [progress, setProgress] = useState>({}); + const [busy, setBusy] = useState<'file' | 'connect' | 'install' | null>(null); + const [err, setErr] = useState(null); + const [done, setDone] = useState(false); + const fileRef = useRef(null); + + const pickFile = async (f: File | undefined) => { + if (!f) return; + setErr(null); setDone(false); setProgress({}); setBusy('file'); + try { setXapk(await openXapk(f)); } + catch (e) { setXapk(null); setErr((e as Error).message); } + finally { setBusy(null); } + }; + + const connect = async () => { + setErr(null); setBusy('connect'); + try { setHeadset(await connectHeadset()); } + catch (e) { + const m = (e as Error).message; + // Closing the browser's device picker isn't an error worth showing. + if (!/No device selected|No headset selected/i.test(m)) setErr(m); + } + finally { setBusy(null); } + }; + + const install = async () => { + if (!xapk || !headset) return; + setErr(null); setDone(false); setProgress({}); setBusy('install'); + try { + await installXapk( + headset, + xapk, + (p) => setProgress((prev) => ({ ...prev, [p.phase]: p })), + async (pkg) => window.confirm( + `An older copy of ${pkg} is installed with a different signing key and has to be removed first.\n\n` + + 'Your account is on the server, but local settings on the headset will be lost. Continue?'), + ); + setDone(true); + } catch (e) { + const m = String((e as Error)?.message ?? e); + setErr(/disconnected|NetworkError|transfer/i.test(m) + ? 'The headset disconnected. Keep it plugged in and awake (wear it or cover the sensor), reconnect and try again.' + : m); + if (/disconnected|NetworkError/i.test(m)) setHeadset(null); + } finally { + setBusy(null); + } + }; + + const name = xapk?.manifest.name || 'DorkNet'; + const totalSize = xapk ? xapk.apk.size + xapk.expansions.reduce((s, x) => s + x.entry.size, 0) : 0; + + return ( +
+
+

Install on Meta Quest

+

+ Installs the DorkNet Quest build straight from your browser: no SideQuest or command line. + Your headset needs developer mode on (Meta Horizon phone app → your + headset → Developer mode). +

+
+ + {!supported && ( +
+ This browser can't talk to USB devices. Open this page in Chrome or Edge on a computer + (Windows, macOS, Linux or ChromeOS). +
+ )} + + +

Pick the DorkNet-….xapk you were given. It stays on your computer; nothing is uploaded.

+ pickFile(e.target.files?.[0])} /> +
+ + {xapk && ( + + {name} {xapk.manifest.version_name} · {fmtSize(totalSize)} + + )} +
+
+ + +
    +
  1. Plug the Quest into this computer with a USB cable.
  2. +
  3. Click Connect and pick the Quest in the browser's list.
  4. +
  5. Put the headset on and tap Allow on "Allow USB debugging" (tick "Always allow").
  6. +
+

If it says the device is in use, close SideQuest / Meta Quest Developer Hub, or run adb kill-server.

+
+ + {headset && Connected: {headset.model}} +
+
+ + +

+ Installs the app and copies the game data. The game data is large, so this takes a few minutes. Keep the + cable in and the headset awake (wear it or cover the sensor above the nose). +

+ + {(['app', 'data', 'launch'] as const).map((k) => progress[k] && )} + {done && ( +
+ Installed! The game is starting in the headset. Next time, find it under + Library → Unknown Sources → {name}. +
+ )} +
+ + {err &&
{err}
} + +

+ The app installs alongside the Meta Store version of Rec Room and doesn't replace it. You'll need a DorkNet + account to sign in. +

+
+ ); +} diff --git a/NOTICE b/NOTICE index f035e39..fcfcffd 100644 --- a/NOTICE +++ b/NOTICE @@ -47,6 +47,11 @@ Third-party components text is kept alongside the sources at DorkNet.Quest/src/third_party/And64InlineHook/LICENSE. + The public site's Quest installer page (DorkNet.Server/site, /quest) + bundles Tango (@yume-chan/adb, adb-daemon-webusb, adb-credential-web, + android-bin, stream-extra) by Simon Chan, under the MIT License + (https://github.com/yume-chan/ya-webadb). + -------------------------------------------------------------------------- Acknowledgements diff --git a/README.md b/README.md index 21ec6a4..d2a7f6c 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,14 @@ account. Each hook checks a fingerprint of its target first, so the patch refuses to apply to any other build. You supply your own extracted APK and OBB; no game binaries are -redistributed. Build, install and troubleshooting steps are in -[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view -is in [docs/quest.md](docs/quest.md). +redistributed. The build can package itself as a `.xapk` that players +install from the **Quest** page of your DorkNet site (`https:///quest`) +in Chrome or Edge over USB, with no adb or SideQuest. Build, install and +troubleshooting steps are in [DorkNet.Quest/README.md](DorkNet.Quest/README.md), +and the operator view is in [docs/quest.md](docs/quest.md). -> The Quest build is new and has not yet been tested on a headset. +> Tested on a Quest 2: the hooks arm, the client reaches the server and the +> browser installer works. Full in-game play has not been verified yet. ### Debugging (opt-in — `DorkNet.DebugMod`) diff --git a/docs/quest.md b/docs/quest.md index 5b7472c..5d93992 100644 --- a/docs/quest.md +++ b/docs/quest.md @@ -69,8 +69,11 @@ pitfall in their Quest client notes. 3. Image signing key configured (`ImageSignatureService`), as for PC. 4. Build with `ServerHost=`, the Photon ids and region baked in, passing `-Obb` so you get a release folder (`DorkNet-.xapk` plus - `install.cmd`/`install.sh`). Players run the installer with the headset - plugged in; the app appears as **DorkNet** under Unknown Sources. + `install.cmd`/`install.sh`). Players install it from your site's + **Quest** page (`https:///quest`, Chrome or Edge over USB) or with the + installer scripts; the app appears as **DorkNet** under Unknown Sources. + Share the `.xapk` however you like: the page reads it from the player's own + disk, so your server never hosts game files. No server component is Quest-specific beyond the placeholder-platform-id handling above.