mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38: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:
@@ -8,6 +8,7 @@ import { Players } from './pages/Players';
|
||||
import { PlayerProfile } from './pages/PlayerProfile';
|
||||
import { Rooms } from './pages/Rooms';
|
||||
import { Join } from './pages/Join';
|
||||
import { Downloads } from './pages/Downloads';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
|
||||
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
||||
@@ -27,6 +28,7 @@ export function App() {
|
||||
<Route path="/rooms" element={<Rooms />} />
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { getDownloads } from '../lib/downloads';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Home', exact: true },
|
||||
@@ -10,6 +11,8 @@ const NAV = [
|
||||
{ to: '/join', label: 'Join' },
|
||||
{ to: '/quest', label: 'Quest' },
|
||||
];
|
||||
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
|
||||
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
|
||||
|
||||
export function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -17,6 +20,12 @@ export function Header() {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [hasDownloads, setHasDownloads] = useState(false);
|
||||
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Close the mobile drawer on route change so tapping a nav item
|
||||
// doesn't leave a half-open overlay covering the page.
|
||||
@@ -37,7 +46,7 @@ export function Header() {
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 ml-2">
|
||||
{NAV.map(n => (
|
||||
{navItems.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
@@ -85,7 +94,7 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV.map(n => (
|
||||
{navItems.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
|
||||
const fmtDate = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
|
||||
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
|
||||
];
|
||||
|
||||
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
|
||||
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
|
||||
switch (f.id) {
|
||||
case 'pc-full':
|
||||
return {
|
||||
title: 'Full game', primary: true,
|
||||
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
|
||||
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
|
||||
};
|
||||
case 'pc-patch':
|
||||
return {
|
||||
title: 'Patch only',
|
||||
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
|
||||
DorkNet launcher installs this for you; skip it if you got the full game.</>,
|
||||
};
|
||||
case 'quest':
|
||||
return {
|
||||
title: 'Quest app', primary: true,
|
||||
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
|
||||
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
|
||||
};
|
||||
case 'quest-installer':
|
||||
return {
|
||||
title: 'Install scripts',
|
||||
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
|
||||
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
|
||||
};
|
||||
default:
|
||||
return { title: f.name, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
||||
const d = describe(f, rel);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(f.sha256);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
|
||||
};
|
||||
return (
|
||||
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-ink-50">{d.title}</h3>
|
||||
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
|
||||
</div>
|
||||
<p className="text-sm text-ink-300">{d.body}</p>
|
||||
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
|
||||
<span>{fmtSize(f.size)}</span>
|
||||
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
|
||||
{f.build && <span>Build {f.build}</span>}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
|
||||
{f.id === 'quest' ? (
|
||||
<>
|
||||
<Link className="btn-primary" to="/quest?build=latest">Install on Quest</Link>
|
||||
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
|
||||
</>
|
||||
) : (
|
||||
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`Copy SHA-256: ${f.sha256}`}
|
||||
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
|
||||
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
|
||||
>
|
||||
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Downloads() {
|
||||
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const releases = (m?.releases ?? []).filter((r) => r.files.length);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
|
||||
<p className="text-sm text-ink-400">
|
||||
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
|
||||
lets you check your download.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
|
||||
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
|
||||
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
|
||||
|
||||
{releases.map((rel) => (
|
||||
<section key={rel.id} className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
|
||||
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
|
||||
</div>
|
||||
{PLATFORMS.map((p) => {
|
||||
const files = rel.files.filter((f) => f.platform === p.id);
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<div key={p.id} className="space-y-2">
|
||||
<div>
|
||||
<h3 className="label">{p.title}</h3>
|
||||
<p className="text-xs text-ink-400">{p.hint}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{releases.length > 0 && (
|
||||
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
|
||||
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { openXapk, type Xapk } from '../lib/xapk';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
||||
import { fileUrl, getDownloads, latestQuest } from '../lib/downloads';
|
||||
|
||||
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`;
|
||||
@@ -43,15 +45,35 @@ export function Quest() {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
// The server's latest published Quest build, if it offers downloads.
|
||||
const [latest, setLatest] = useState<ReturnType<typeof latestQuest>>(null);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const pickFile = async (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
const open = async (load: () => Promise<Xapk>) => {
|
||||
setErr(null); setDone(false); setProgress({}); setBusy('file');
|
||||
try { setXapk(await openXapk(f)); }
|
||||
try { setXapk(await load()); }
|
||||
catch (e) { setXapk(null); setErr((e as Error).message); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const pickFile = async (f: File | undefined) => {
|
||||
if (f) await open(() => openXapk(fileSource(f)));
|
||||
};
|
||||
|
||||
const pickLatest = async (l = latest) => {
|
||||
if (l) await open(() => openXapk(urlSource(fileUrl(l.file), l.file.name, l.file.size)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads().then((m) => {
|
||||
const l = latestQuest(m);
|
||||
setLatest(l);
|
||||
// Arriving from the Downloads page's "Install on Quest".
|
||||
if (l && params.get('build') === 'latest') void pickLatest(l);
|
||||
}).catch(() => { /* no downloads: only the file picker */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const connect = async () => {
|
||||
setErr(null); setBusy('connect');
|
||||
try { setHeadset(await connectHeadset()); }
|
||||
@@ -108,19 +130,32 @@ export function Quest() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Step n={1} title="Choose the .xapk file" done={!!xapk}>
|
||||
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
||||
<Step n={1} title={latest ? 'Pick the build' : 'Choose the .xapk file'} done={!!xapk}>
|
||||
{latest ? (
|
||||
<p className="text-sm text-ink-400">
|
||||
Install the latest build straight from this server: nothing to download first, it streams to the
|
||||
headset during the install. Or pick a <code className="text-ink-200">.xapk</code> you already have.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
|
||||
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'}
|
||||
</button>
|
||||
{xapk && (
|
||||
<span className="text-sm text-ink-300">
|
||||
<span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
|
||||
</span>
|
||||
{latest && (
|
||||
<button className="btn-primary" disabled={busy !== null} onClick={() => pickLatest()}>
|
||||
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
|
||||
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
|
||||
</button>
|
||||
</div>
|
||||
{xapk && (
|
||||
<p className="text-sm text-ink-300">
|
||||
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
|
||||
<span className="text-ink-500"> · {xapk.source.name}</span>
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Connect your headset" done={!!headset}>
|
||||
|
||||
Reference in New Issue
Block a user