From 871460393a5a8f0f0168c9982308333f81625323 Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Sun, 11 Oct 2026 20:11:47 +0200 Subject: [PATCH 1/3] Add serialized Dev APK builds and authenticated resumable downloads --- README.md | 2 + docs/dev-apk.md | 59 +++++++++++++++++++ src/client/features/hud/index.ts | 2 + src/client/ui/dev-apk/index.ts | 57 ++++++++++++++++++ src/client/ui/dev-apk/ui.css | 15 +++++ src/server/dev-apk/config.ts | 28 +++++++++ src/server/dev-apk/download.ts | 39 ++++++++++++ src/server/dev-apk/manager.ts | 58 ++++++++++++++++++ src/server/dev-apk/powershell.ts | 34 +++++++++++ src/server/dev-apk/runner.ts | 51 ++++++++++++++++ src/server/dev-apk/storage.ts | 46 +++++++++++++++ src/server/http/routes/dev-apk.ts | 29 +++++++++ src/server/http/routes/index.ts | 2 + src/shared/dev-apk.ts | 21 +++++++ tests/dev-apk-browser.mjs | 54 +++++++++++++++++ tests/dev-apk.test.ts | 98 +++++++++++++++++++++++++++++++ 16 files changed, 595 insertions(+) create mode 100644 docs/dev-apk.md create mode 100644 src/client/ui/dev-apk/index.ts create mode 100644 src/client/ui/dev-apk/ui.css create mode 100644 src/server/dev-apk/config.ts create mode 100644 src/server/dev-apk/download.ts create mode 100644 src/server/dev-apk/manager.ts create mode 100644 src/server/dev-apk/powershell.ts create mode 100644 src/server/dev-apk/runner.ts create mode 100644 src/server/dev-apk/storage.ts create mode 100644 src/server/http/routes/dev-apk.ts create mode 100644 src/shared/dev-apk.ts create mode 100644 tests/dev-apk-browser.mjs create mode 100644 tests/dev-apk.test.ts diff --git a/README.md b/README.md index fac9441a5..f854f5ad8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ # 🏢 Agent Office +Dev-APKs für entfernte Tester: **☰ → Dev-APK** startet den eingerichteten Quest-Development-Build und bietet die neueste erfolgreiche APK hinter dem Office-Login zum Download an, auch über den bestehenden HTTPS-Tunnel. [Einrichtung und Bedienung](docs/dev-apk.md). + **A 3D office your team shares with its coding agents.** Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, diff --git a/docs/dev-apk.md b/docs/dev-apk.md new file mode 100644 index 000000000..d67fa0d11 --- /dev/null +++ b/docs/dev-apk.md @@ -0,0 +1,59 @@ +# Dev-APK bauen und herunterladen + +Im Office: **☰ → Dev-APK**. Der Eintrag lässt sich oben anheften. Er gehört zum gesamten Projekt, nicht zu einem einzelnen Playtest. Der Tester braucht zuhause kein Unity. + +## Ablauf + +1. Ein freigeschalteter Nutzer drückt **Dev-APK bauen**. Der Server startet `Tools/Build-Quest.ps1 -Development` auf dem Build-PC. +2. Läuft bereits ein Unity-Build oder Test, wartet der Auftrag auf denselben globalen Build-Platz. Status und Protokoll sind sichtbar. Ein zweiter Office-Build wird währenddessen abgelehnt. +3. Der konfigurierte Branch wird frisch abgerufen und auf einen Commit festgelegt. Der gemeinsame Arbeitscheckout wird weder zurückgesetzt noch umgeschaltet. Gebaut wird im vorbereiteten Build-Ordner. +4. Erst nach erfolgreichem Build, vollständiger Kopie und SHA-256-Berechnung wird der Download veröffentlicht. Ein Fehlschlag lässt die letzte erfolgreiche APK unverändert. +5. **APK herunterladen** lädt genau die angezeigte Version. **Download-Link kopieren** liefert einen dauerhaften Link auf die neueste erfolgreiche APK. Der Empfänger benötigt seinen Office-Login. + +Der eigene Hintergrundprozess läuft auch bei geschlossenem Browser oder Office-Neustart weiter. Stirbt er, wird der Auftrag als unterbrochen angezeigt. Ein möglicherweise noch laufendes Unity wird nicht beendet; der nächste Build wartet weiterhin auf den gemeinsamen Mutex. + +## Einrichtung auf dem Windows-Build-PC + +Im Office-Datenordner (`/.agent-office`, passend zu `--home`) die Datei `dev-apk.json` anlegen. Die Konfiguration wird bei jedem Aufruf neu gelesen und ausschließlich auf dem Server bearbeitet. Browser können keine Befehle, Ordner oder Branches vorgeben. + +```json +{ + "projects": [{ + "repo": "KajoVanBuyten/zombie-reality-breach", + "sourceDir": "E:/source/Unity/zombie-reality-breach", + "buildDir": "E:/source/Unity/zrb-build", + "ref": "origin/main", + "apkName": "ZombieRealityBreach-dev.apk", + "launcher": "C:/Users/gp/KaMaStudios/tools/unity-hidden.ps1", + "builders": [] + }] +} +``` + +Voraussetzungen: Node, Git, Windows PowerShell, Unity/Android und der funktionierende ZRB-Build-Ablauf inklusive `Tools/BuildLock.ps1`. `sourceDir` ist ein vertrauenswürdiger Projektcheckout mit den erforderlichen Assets; `buildDir` ist ausschließlich ein wegwerfbarer Build-Checkout, niemals ein Arbeitscheckout. Der Office-Prozess muss Zugriff darauf haben. Der vorhandene Build-Ablauf kopiert seine Ausgabe nach `sourceDir/Builds/Quest`; die Download-Kopie wird noch unter demselben Build-Mutex erstellt. + +Admins und Nutzer des gemeinsamen Office-Passworts dürfen Builds starten. Für weitere Co-Devs deren **Office-Konto-ID** in `builders` eintragen. Alle angemeldeten Office-Nutzer dürfen herunterladen. Keine Issues oder Worker/Queue-Einträge werden erzeugt. + +Der Branch ist in der Oberfläche sichtbar. Für XR-Playtest-Werkzeuge muss der gewählte Stand den Spiel-PR #361 enthalten. Ein Development-Build fügt keine noch nicht integrierten Änderungen hinzu. + +## Tunnel und Downloads + +Die APK liegt unter derselben Office-Adresse: beispielsweise `https://studios.zombierealitybreach.com/api/dev-apk/download?floor=`. Der vorhandene HTTPS-/SSH-Tunnel transportiert den authentifizierten HTTP-Download. Kein zusätzlicher Port oder lokaler `agent-office tunnel`-Helfer ist nötig. + +GET/HEAD, Dateigröße, ETag, Range und If-Range ermöglichen fortsetzbare Downloads. Dateien werden gestreamt. Versionierte Links bleiben während neuer Builds unverändert; If-Range verhindert beim Fortsetzen des „neueste“-Links das Vermischen verschiedener Versionen. + +Der Proxy muss Cookies und Range-Header weiterleiten, die API darf nicht öffentlich gecacht werden. `X-Accel-Buffering: no` wird gesendet. Bei nginx gegebenenfalls `proxy_buffering off` und ausreichend langen `proxy_read_timeout` für den Download-Pfad konfigurieren. `client_max_body_size` begrenzt Uploads, nicht diese Downloads. Build-PC und Tunnel müssen online bleiben. + +## Installation beim Tester + +APK auf dem eigenen PC speichern, Quest-Entwicklermodus aktivieren, USB-Debugging freigeben und mit **Meta Quest Developer Hub** oder `adb install -r ` installieren. Unity ist nicht nötig. Der Quest-Browser installiert eine heruntergeladene APK nicht automatisch. + +Bei anderer Signatur als der Store-Version kann Android das Update ablehnen. Nicht automatisch deinstallieren: Lokale Spieldaten könnten verloren gehen. + +Meta verlangt auch für Alpha/Beta/RC die Release-Verpackungsregeln. Für unsere debuggable Dev-APK nutzen wir Sideloading; signierte Release-Builds können über Meta-Testkanäle verteilt werden. Quellen: [Release-Kanäle](https://developers.meta.com/horizon/resources/publish-release-channels/) und [Manifest-Regeln](https://developers.meta.com/horizon/resources/publish-mobile-manifest/). + +## Speicherung und Prüfung + +Unter `/dev-apk/` liegen Aufträge, Protokolle und APKs. Die letzten drei erfolgreichen APKs bleiben erhalten. Ältere APK-Dateien werden nach erfolgreicher Veröffentlichung entfernt; Metadaten und Logs bleiben bestehen. Partielle oder fehlgeschlagene Builds ersetzen niemals den neuesten erfolgreichen Download. + +Prüfung: `npm run typecheck`, `npm test`, `npm run build`; gezielt `node --import tsx --test tests/dev-apk.test.ts tests/size.test.ts`; Browser-Abnahme `node --import tsx tests/dev-apk-browser.mjs`. HTTP-/Browser-Tests mit Testdateien sind getrennt von echten Unity-Builds, öffentlichen Tunnel-Downloads und Headset-Installation zu berichten. diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..355d76c0a 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -18,6 +18,7 @@ import { toggleFloorMenu } from '../../ui/floormenu'; import { openHelp } from '../../ui/hud'; import { mountHud } from '../../ui/menu'; import { openServices } from '../../ui/services'; +import { devApkAction } from '../../ui/dev-apk'; import { openSettings, type SettingsPane } from '../../ui/settings'; import { needsSigningIn, openSignIns } from '../../ui/signins'; import { openTeam } from '../../ui/team'; @@ -49,6 +50,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { const noMedia = () => (window.isSecureContext ? undefined : 'Voice and screen sharing need HTTPS or localhost — use a TLS proxy, --self-signed, or an SSH tunnel'); const hud = mountHud( [ + devApkAction, { id: 'issues', icon: '📌', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, diff --git a/src/client/ui/dev-apk/index.ts b/src/client/ui/dev-apk/index.ts new file mode 100644 index 000000000..911c21977 --- /dev/null +++ b/src/client/ui/dev-apk/index.ts @@ -0,0 +1,57 @@ +import './ui.css'; +import { h, openModal, type Modal } from '../dom'; +import { store } from '../../state'; +import type { HudAction } from '../menu'; +import type { DevApkBuild, DevApkView } from '../../../shared/dev-apk'; +const labels: Record = { starting: 'Build wird gestartet', waiting: 'Warte auf freien Build-Platz', building: 'Dev-APK wird gebaut', publishing: 'Download wird vorbereitet', ready: 'Build erfolgreich', failed: 'Build fehlgeschlagen', interrupted: 'Build unterbrochen' }; +let modal: Modal | undefined; +export const devApkAction: HudAction = { id: 'dev-apk', icon: '📦', label: 'Dev-APK', section: 'Open', run: () => openDevApk() }; +export function openDevApk() { + if (modal) return; + const floor = store.floor; if (!floor) return; + const url = '/api/dev-apk?floor=' + encodeURIComponent(floor); + const status = h('div.dev-apk-status', { role: 'status' }, 'Lade Build-Status…'); + const latest = h('section.dev-apk-card'), current = h('section.dev-apk-card'); + const log = h('pre.dev-apk-log'), error = h('p.dev-apk-error', { role: 'alert' }); + const logDetails = h('details', {}, h('summary', {}, 'Build-Protokoll'), log); + let stopped = false, pending = false; + const startButton = h('button.btn.primary', { type: 'button', disabled: true, onclick: async () => { + pending = true; startButton.disabled = true; error.textContent = ''; + try { const response = await fetch(url, { method: 'POST' }); const result = await response.json(); if (!response.ok) throw Error(result.error); } + catch (e) { error.textContent = (e as Error).message; } + finally { pending = false; await refresh(); } + } }, 'Dev-APK bauen'); + const link = (id?: string) => '/api/dev-apk/download?floor=' + encodeURIComponent(floor) + (id ? '&id=' + encodeURIComponent(id) : ''); + const copyLink = h('button.btn', { type: 'button', onclick: async () => { + try { await navigator.clipboard.writeText(new URL(link(), location.href).href); error.textContent = 'Download-Link kopiert. Der Empfänger benötigt seinen Office-Login.'; } + catch { error.textContent = new URL(link(), location.href).href; } + } }, 'Download-Link kopieren'); + async function refresh() { + if (stopped) return; + try { + const response = await fetch(url); const data: DevApkView & { error?: string } = await response.json(); if (!response.ok) throw Error(data.error); + if (stopped) return; + startButton.disabled = pending || !data.canBuild || !!data.busy; + status.textContent = !data.configured ? 'Für dieses Projekt ist noch kein Build-Rechner eingerichtet.' : data.busy ? (data.current && !['ready', 'failed', 'interrupted'].includes(data.current.status) ? labels[data.current.status] : 'Ein anderes Projekt wird gerade gebaut.') : 'Bereit'; + current.replaceChildren(h('div', {}, h('h3', {}, 'Neuen Dev-Build erstellen'), h('p', {}, data.configured ? `Quelle: ${data.ref}. Der Server baut den neuesten Stand dieses Branches.` : 'Der Office-Administrator richtet den Build-Ablauf einmal auf dem Server ein.'), startButton, + data.configured && !data.canBuild ? h('p', {}, 'Du kannst APKs herunterladen. Für Build-Starts muss dich ein Administrator freischalten.') : null, + data.current ? h('p', {}, `${labels[data.current.status]} · ${new Date(data.current.startedAt).toLocaleString()} · ${data.current.requestedBy}`, data.current.error ? h('span', {}, ' — ' + data.current.error) : null) : null)); + latest.replaceChildren(h('h3', {}, 'Neueste erfolgreiche Dev-APK')); + if (data.latest) { + const apk = data.latest; + latest.append(h('p', {}, `${new Date(apk.finishedAt!).toLocaleString()} · ${Math.round(apk.bytes! / 1048576)} MB · Commit ${apk.revision?.slice(0, 12)}`), + h('div.dev-apk-actions', {}, h('a.btn.primary', { href: link(apk.id) }, 'APK herunterladen'), copyLink), + h('details', {}, h('summary', {}, 'SHA-256 prüfen'), h('code', {}, apk.sha256 ?? ''))); + } else latest.append(h('p', {}, 'Noch keine APK vorhanden. Nach dem ersten erfolgreichen Build erscheint hier der Download.')); + log.textContent = data.log || 'Noch kein Build-Protokoll.'; + } catch (e) { if (!stopped) { error.textContent = (e as Error).message; startButton.disabled = true; } } + } + modal = openModal(h('section.dev-apk-dialog', { role: 'dialog', 'aria-label': 'Dev-APK', 'aria-modal': 'true' }, + h('header', {}, h('h2', {}, '📦 Dev-APK')), h('div.dev-apk-body', {}, + h('p', {}, 'Dev-Versionen bauen und mit deinem Testteam teilen. Unity wird nur auf dem Build-Rechner benötigt.'), status, latest, current, error, logDetails, + h('details', {}, h('summary', {}, 'Auf der Quest installieren'), h('p', {}, 'APK auf dem eigenen PC herunterladen. Quest im Entwicklermodus per USB verbinden und die Datei mit Meta Quest Developer Hub oder ADB installieren. Unity ist dafür nicht nötig.'), + h('p', {}, 'Bei unterschiedlicher Signatur zur Store-Version ist kein direktes Update möglich. Eine vorhandene App nicht ohne Sicherung ihrer Daten deinstallieren.')))), { onClose: () => { stopped = true; clearInterval(timer); off(); modal = undefined; } }); + const timer = setInterval(() => { void refresh(); }, 2500); + const off = store.on('floor', () => { if (store.floor !== floor) modal?.close(); }); + void refresh(); +} diff --git a/src/client/ui/dev-apk/ui.css b/src/client/ui/dev-apk/ui.css new file mode 100644 index 000000000..847fc3594 --- /dev/null +++ b/src/client/ui/dev-apk/ui.css @@ -0,0 +1,15 @@ +.dev-apk-dialog { width: min(820px, 95vw); max-height: 92vh; display: flex; flex-direction: column; background: #fffaf0; color: #303048; border: 3px solid #303048; border-radius: 20px; overflow: hidden; } +.dev-apk-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 3px solid #303048; background: #fff2d9; } +.dev-apk-dialog h2 { margin: 0; } +.dev-apk-body { padding: 20px; overflow: auto; display: grid; gap: 16px; } +.dev-apk-body p { margin: 0 0 12px; line-height: 1.5; } +.dev-apk-status { background: #d6f4df; border: 2px solid #303048; border-radius: 10px; padding: 12px; font-weight: bold; } +.dev-apk-card { border: 2px solid #303048; border-radius: 12px; padding: 16px; } +.dev-apk-card h3 { margin: 0 0 14px; } +.dev-apk-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } +.dev-apk-dialog .btn { white-space: normal; text-decoration: none; } +.dev-apk-dialog code { overflow-wrap: anywhere; } +.dev-apk-dialog summary { cursor: pointer; } +.dev-apk-log { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow: auto; font-size: 12px; background: #222231; color: #f8f8ff; padding: 12px; } +.dev-apk-error { color: #9a2538; } +@media (max-width: 500px) { .dev-apk-body { padding: 12px; } .dev-apk-card { padding: 12px; } } diff --git a/src/server/dev-apk/config.ts b/src/server/dev-apk/config.ts new file mode 100644 index 000000000..f9e267ead --- /dev/null +++ b/src/server/dev-apk/config.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +export interface DevApkConfig { + repo: string; + sourceDir: string; + buildDir: string; + ref: string; + apkName: string; + launcher?: string; + builders?: string[]; +} +export class BuildError extends Error { constructor(message: string, public status = 400) { super(message); } } +export function configFor(dataDir: string, repo: string | undefined): DevApkConfig | undefined { + const file = path.join(dataDir, 'dev-apk.json'); + if (!repo || !existsSync(file)) return; + const configs = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')).projects; + if (!Array.isArray(configs)) throw new BuildError('Dev-APK-Konfiguration ist ungültig.', 503); + const c = configs.find(c => c.repo?.toLowerCase() === repo.toLowerCase()); + if (!c) return; + if (!path.isAbsolute(c.sourceDir ?? '') || !path.isAbsolute(c.buildDir ?? '') || + !/^origin\/[a-zA-Z0-9][a-zA-Z0-9._/-]{0,180}$/.test(c.ref ?? '') || c.ref.includes('..') || + !/^[a-zA-Z0-9_-]+\.apk$/.test(c.apkName ?? '') || (c.launcher && !path.isAbsolute(c.launcher)) || + (c.builders !== undefined && (!Array.isArray(c.builders) || c.builders.some((b: unknown) => typeof b !== 'string')))) throw new BuildError('Dev-APK-Konfiguration prüfen: absolute Ordner und gültiger origin-Branch erforderlich.', 503); + const source = path.resolve(c.sourceDir), build = path.resolve(c.buildDir); + const relative = path.relative(build, source); + if (!relative || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) throw new BuildError('Build-Ordner darf den Quellordner nicht enthalten.', 503); + return { ...c, sourceDir: source, buildDir: build }; +} diff --git a/src/server/dev-apk/download.ts b/src/server/dev-apk/download.ts new file mode 100644 index 000000000..27cbc5860 --- /dev/null +++ b/src/server/dev-apk/download.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { open } from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { jobDir, readJson, type Job } from './storage.js'; +import { latest } from './manager.js'; +import { BuildError } from './config.js'; +export function byteRange(header: string, size: number): { start: number; end: number } | undefined { + const match = /^bytes=(\d*)-(\d*)$/.exec(header); + if (!match || (!match[1] && !match[2])) return; + const a = Number(match[1]), b = Number(match[2]); + if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) return; + const start = match[1] ? a : Math.max(0, size - b), end = match[1] && match[2] ? Math.min(b, size - 1) : size - 1; + return start <= end && start < size ? { start, end } : undefined; +} +export async function download(root: string, project: string, id: string | null, req: IncomingMessage, res: ServerResponse) { + const job = id ? readJson(path.join(jobDir(root, project, id), 'job.json')) : latest(root, project); + if (!job || job.status !== 'ready' || !job.sha256 || !job.bytes) throw new BuildError('Noch keine erfolgreiche APK vorhanden.', 404); + const file = await open(path.join(jobDir(root, project, job.id), 'app.apk'), 'r').catch(() => { throw new BuildError('Dieser ältere Download ist nicht mehr vorhanden.', 410); }); + try { + const stat = await file.stat(); if (stat.size !== job.bytes) throw new BuildError('APK-Prüfung fehlgeschlagen.', 503); + const etag = `"${job.sha256}"`, modified = new Date(job.finishedAt!).toUTCString(); + let range; + const ifRange = req.headers['if-range']; + if (req.headers.range && (!ifRange || ifRange === etag || ifRange === modified)) { + range = byteRange(req.headers.range, stat.size); + if (!range) { res.writeHead(416, { 'content-range': `bytes */${stat.size}` }).end(); return; } + } + const headers: Record = { 'content-type': 'application/vnd.android.package-archive', + 'content-disposition': `attachment; filename="dev-${job.revision?.slice(0, 12) ?? job.id}.apk"`, + 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-accel-buffering': 'no', + 'accept-ranges': 'bytes', etag, 'last-modified': modified, + 'content-length': String(range ? range.end - range.start + 1 : stat.size) }; + if (range) headers['content-range'] = `bytes ${range.start}-${range.end}/${stat.size}`; + res.writeHead(range ? 206 : 200, headers); + if (req.method === 'HEAD') { res.end(); return; } + await pipeline(file.createReadStream({ ...range, autoClose: false }), res); + } finally { await file.close(); } +} diff --git a/src/server/dev-apk/manager.ts b/src/server/dev-apk/manager.ts new file mode 100644 index 000000000..e80b08256 --- /dev/null +++ b/src/server/dev-apk/manager.ts @@ -0,0 +1,58 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Session } from '../auth.js'; +import type { DevApkView } from '../../shared/dev-apk.js'; +import { BuildError, configFor, type DevApkConfig } from './config.js'; +import { activeJob, atomic, jobDir, projectFor, publicJob, readJson, release, reserve, rootFor, saveJob, type Job } from './storage.js'; +export const canBuild = (s: Session, c: DevApkConfig) => !s.account || s.account.role === 'admin' || !!c.builders?.includes(s.account.id); +export function latest(root: string, project: string) { + const index = readJson<{ id: string }>(path.join(root, project, 'latest.json')); + return index ? readJson(path.join(jobDir(root, project, index.id), 'job.json')) : undefined; +} +function tail(file: string) { + if (!existsSync(file)) return ''; + const fd = openSync(file, 'r'); + try { const length = statSync(file).size, buffer = Buffer.alloc(Math.min(length, 16000)); readSync(fd, buffer, 0, buffer.length, length - buffer.length); return buffer.toString('utf8'); } + finally { closeSync(fd); } +} +export function view(dataDir: string, repo: string | undefined, session: Session): DevApkView { + const c = configFor(dataDir, repo); if (!c) return { configured: false, canBuild: false }; + const root = rootFor(dataDir), project = projectFor(c.repo), active = activeJob(root); + const recent = readJson<{ id: string }>(path.join(root, project, 'recent.json')); + const current = recent && readJson(path.join(jobDir(root, project, recent.id), 'job.json')); + if (current && ['waiting', 'starting'].includes(current.status)) { + const phase = path.join(jobDir(root, project, current.id), 'phase.txt'); + if (existsSync(phase)) { const p = readFileSync(phase, 'utf8').trim().replace(/^\uFEFF/, ''); if (['waiting', 'building', 'publishing'].includes(p)) current.status = p as Job['status']; } + } + if (current && !current.revision) { + const revision = path.join(jobDir(root, project, current.id), 'revision.txt'); + if (existsSync(revision)) current.revision = readFileSync(revision, 'utf8').trim().replace(/^\uFEFF/, ''); + } + return { configured: true, canBuild: canBuild(session, c), ref: c.ref, latest: publicJob(latest(root, project)), current: publicJob(current), busy: !!active, + log: current ? tail(path.join(jobDir(root, project, current.id), 'build.log')) : '' }; +} +export function start(dataDir: string, repo: string | undefined, session: Session, launch = launchRunner) { + const config = configFor(dataDir, repo); if (!config) throw new BuildError('Für dieses Projekt ist noch kein Dev-Build eingerichtet.', 404); + if (!canBuild(session, config)) throw new BuildError('Nur freigeschaltete Build-Nutzer dürfen einen Build starten.', 403); + if (!existsSync(path.join(config.sourceDir, 'Tools', 'Build-Quest.ps1')) || !existsSync(path.join(config.sourceDir, 'Tools', 'BuildLock.ps1'))) throw new BuildError('Der konfigurierte Quest-Build-Ablauf fehlt auf dem Server.', 503); + const root = rootFor(dataDir), project = projectFor(config.repo); + const job: Job = { id: randomUUID(), project, config, ref: config.ref, pid: process.pid, status: 'starting', startedAt: Date.now(), requestedBy: session.account?.name ?? 'Office' }; + reserve(root, job); + try { + saveJob(root, job); atomic(path.join(root, project, 'recent.json'), { id: job.id }); + launch(root, job); + } catch (e) { job.status = 'failed'; job.error = (e as Error).message; job.finishedAt = Date.now(); saveJob(root, job); release(root, job.id); throw e; } + return publicJob(job); +} +function launchRunner(root: string, job: Job) { + const file = fileURLToPath(new URL('./runner.js', import.meta.url)); + if (!existsSync(file)) throw new BuildError('Office zuerst mit npm run build bauen.', 503); + const child = spawn(process.execPath, [file, root, job.project, job.id], { detached: true, windowsHide: true, stdio: 'ignore' }); + if (child.pid) atomic(path.join(root, 'active.json'), { id: job.id, project: job.project, pid: child.pid }); + child.once('error', error => { job.status = 'failed'; job.error = error.message; job.finishedAt = Date.now(); saveJob(root, job); release(root, job.id); }); + // Only update the lock's PID here; the worker owns subsequent job status writes. + child.unref(); +} diff --git a/src/server/dev-apk/powershell.ts b/src/server/dev-apk/powershell.ts new file mode 100644 index 000000000..377aa65cf --- /dev/null +++ b/src/server/dev-apk/powershell.ts @@ -0,0 +1,34 @@ +/** Constant script, arguments come from a local job file, never from shell interpolation. */ +export const buildScript = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$jobFile = $env:OFFICE_DEV_APK_JOB +$job = Get-Content -LiteralPath $jobFile -Raw | ConvertFrom-Json +$folder = Split-Path -Parent $jobFile +$source = $job.config.sourceDir +$script = Join-Path $source 'Tools\Build-Quest.ps1' +. (Join-Path $source 'Tools\BuildLock.ps1') +Set-Content -LiteralPath (Join-Path $folder 'phase.txt') -Value 'waiting' +$lock = Enter-BuildLock 'Global\zrb-build-folder' -OnWait { Write-Output 'Warte auf den gemeinsamen Unity-Build-Platz...' } -OnAbandoned { Write-Output 'Verlassenen Build-Platz übernommen.' } +try { + Set-Content -LiteralPath (Join-Path $folder 'phase.txt') -Value 'building' + & git -C $source fetch --quiet origin + if ($LASTEXITCODE -ne 0) { throw 'Abruf des aktuellen Branches fehlgeschlagen. Kein veralteter Build.' } + $revision = (& git -C $source rev-parse --verify ($job.config.ref + '^{commit}')).Trim() + if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[a-f0-9]{40}$') { throw 'Build-Commit konnte nicht ermittelt werden.' } + Set-Content -LiteralPath (Join-Path $folder 'revision.txt') -Value $revision + Write-Output ('Baue Commit ' + $revision) + $started = [DateTime]::UtcNow + $arguments = @{ Development = $true; Ref = $revision; Main = $source; Folder = $job.config.buildDir } + if ($job.config.launcher) { $arguments.Launcher = $job.config.launcher } + & $script @arguments + if ($LASTEXITCODE -ne 0) { throw 'Build-Script fehlgeschlagen.' } + $apk = Join-Path $source ('Builds\Quest\' + $job.config.apkName) + $file = Get-Item -LiteralPath $apk + if ($file.Length -lt 4 -or $file.LastWriteTimeUtc -lt $started.AddSeconds(-2)) { throw 'Keine neue APK erzeugt. Vorherige APK wird nicht als neuer Build veröffentlicht.' } + Set-Content -LiteralPath (Join-Path $folder 'phase.txt') -Value 'publishing' + # Copy under the same named mutex: the next Unity job cannot replace this file during publication. + Copy-Item -LiteralPath $apk -Destination (Join-Path $folder 'app.apk.part') +} finally { $lock.ReleaseMutex(); $lock.Dispose() } +`; diff --git a/src/server/dev-apk/runner.ts b/src/server/dev-apk/runner.ts new file mode 100644 index 000000000..222dab0c2 --- /dev/null +++ b/src/server/dev-apk/runner.ts @@ -0,0 +1,51 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { closeSync, createReadStream, existsSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { atomic, jobDir, readJson, release, saveJob, type Job } from './storage.js'; +import { buildScript } from './powershell.js'; +export async function publish(root: string, job: Job) { + const dir = jobDir(root, job.project, job.id), part = path.join(dir, 'app.apk.part'); + const stream = createReadStream(part); const hash = createHash('sha256'); let first = true; + for await (const chunk of stream) { + const bytes = chunk as Buffer; + if (first && (bytes.length < 4 || bytes.readUInt32LE(0) !== 0x04034b50)) throw new Error('Build hat keine APK-Datei erzeugt.'); + first = false; hash.update(bytes); + } + if (first) throw new Error('Leere APK.'); + job.bytes = statSync(part).size; job.sha256 = hash.digest('hex'); + job.revision = readFileSync(path.join(dir, 'revision.txt'), 'utf8').trim().replace(/^\uFEFF/, ''); + if (!/^[a-f0-9]{40}$/.test(job.revision)) throw new Error('Build-Revision fehlt.'); + renameSync(part, path.join(dir, 'app.apk')); + job.status = 'ready'; job.finishedAt = Date.now(); saveJob(root, job); + atomic(path.join(root, job.project, 'latest.json'), { id: job.id }); + // Keep the last three successful APKs. Never unlink a file being streamed on Windows. + const ready = readdirSync(path.join(root, job.project)).filter(id => /^[a-f0-9-]{36}$/.test(id)) + .map(id => readJson(path.join(jobDir(root, job.project, id), 'job.json'))).filter(j => j?.status === 'ready') as Job[]; + for (const old of ready.sort((a, b) => b.finishedAt! - a.finishedAt!).slice(3)) { + try { unlinkSync(path.join(jobDir(root, old.project, old.id), 'app.apk')); } catch { /* Retry retention after the next successful build. */ } + } +} +export async function run(root: string, project: string, id: string) { + const dir = jobDir(root, project, id), job = readJson(path.join(dir, 'job.json'))!; + job.pid = process.pid; job.status = 'waiting'; saveJob(root, job); + const log = openSync(path.join(dir, 'build.log'), 'a'); + try { + if (process.platform !== 'win32') throw new Error('Der konfigurierte Quest-Build benötigt einen Windows-Build-Rechner.'); + const code = await new Promise((resolve, reject) => { + const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(buildScript, 'utf16le').toString('base64')], { + windowsHide: true, stdio: ['ignore', log, log], env: { ...process.env, OFFICE_DEV_APK_JOB: path.join(dir, 'job.json') }, + }); + child.once('error', reject); child.once('exit', resolve); + }); + if (code !== 0) throw new Error('Dev-Build fehlgeschlagen. Details im Build-Protokoll.'); + await publish(root, job); + } catch (error) { + job.status = 'failed'; job.finishedAt = Date.now(); job.error = (error as Error).message; saveJob(root, job); + const part = path.join(dir, 'app.apk.part'); if (existsSync(part)) unlinkSync(part); + } finally { closeSync(log); release(root, id); } +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await run(process.argv[2], process.argv[3], process.argv[4]); +} diff --git a/src/server/dev-apk/storage.ts b/src/server/dev-apk/storage.ts new file mode 100644 index 000000000..c333e7d62 --- /dev/null +++ b/src/server/dev-apk/storage.ts @@ -0,0 +1,46 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { DevApkBuild } from '../../shared/dev-apk.js'; +import type { DevApkConfig } from './config.js'; +import { BuildError } from './config.js'; +export interface Job extends DevApkBuild { config: DevApkConfig; pid: number; project: string } +export const rootFor = (dir: string) => path.join(dir, 'dev-apk'); +export const projectFor = (repo: string) => createHash('sha256').update(repo.toLowerCase()).digest('hex').slice(0, 24); +export const jobDir = (root: string, project: string, id: string) => { + if (!/^[a-f0-9]{24}$/.test(project) || !/^[a-f0-9-]{36}$/.test(id)) throw new BuildError('Ungültiger Build.', 404); + return path.join(root, project, id); +}; +export const readJson = (file: string): T | undefined => existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined; +export function atomic(file: string, data: unknown) { + mkdirSync(path.dirname(file), { recursive: true }); + const temp = file + '.' + randomUUID() + '.tmp'; + writeFileSync(temp, JSON.stringify(data), { mode: 0o600 }); renameSync(temp, file); +} +export function saveJob(root: string, job: Job) { atomic(path.join(jobDir(root, job.project, job.id), 'job.json'), job); } +export function publicJob(job?: Job): DevApkBuild | undefined { + if (!job) return; + const { config: _, pid: __, project: ___, ...info } = job; return info; +} +export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; } }; +export function activeJob(root: string): Job | undefined { + const active = readJson<{ project: string; id: string; pid: number }>(path.join(root, 'active.json')); + if (!active) return; + const file = path.join(jobDir(root, active.project, active.id), 'job.json'); + const job = readJson(file); + if (alive(active.pid)) return job; + if (job && !['ready', 'failed', 'interrupted'].includes(job.status)) { + job.status = 'interrupted'; job.finishedAt = Date.now(); job.error = 'Build-Begleiter wurde beendet. Letzter erfolgreicher Download bleibt erhalten.'; saveJob(root, job); + } + release(root, active.id); return; +} +export function reserve(root: string, job: Job) { + mkdirSync(root, { recursive: true }); + activeJob(root); + try { const fd = openSync(path.join(root, 'active.json'), 'wx'); try { writeFileSync(fd, JSON.stringify({ id: job.id, project: job.project, pid: job.pid })); } finally { closeSync(fd); } } + catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') throw new BuildError('Ein Dev-Build läuft bereits. Bitte dessen Abschluss abwarten.', 409); throw e; } +} +export function release(root: string, id: string) { + const file = path.join(root, 'active.json'); + if (readJson<{ id: string }>(file)?.id === id) unlinkSync(file); +} diff --git a/src/server/http/routes/dev-apk.ts b/src/server/http/routes/dev-apk.ts new file mode 100644 index 000000000..fc2194d6d --- /dev/null +++ b/src/server/http/routes/dev-apk.ts @@ -0,0 +1,29 @@ +import type { Route } from '../router.js'; +import { sameOrigin, send } from '../util.js'; +import { floorParam } from './files.js'; +import { start, view } from '../../dev-apk/manager.js'; +import { configFor, BuildError } from '../../dev-apk/config.js'; +import { download } from '../../dev-apk/download.js'; +import { projectFor, rootFor } from '../../dev-apk/storage.js'; +export const devApkRoute: Route = { + path: ['/api/dev-apk', '/api/dev-apk/download'], auth: 'session', + async handle(ctx, { req, res, url, path, session }) { + const floor = floorParam(ctx, url); if (!floor) return send(res, 404, { error: 'Projekt nicht gefunden.' }); + try { + if (path.endsWith('/download')) { + if (!['GET', 'HEAD'].includes(req.method ?? '')) return send(res, 405, { error: 'GET oder HEAD verwenden.' }); + const config = configFor(ctx.cfg.dataDir, floor.def.repo); + if (!config) return send(res, 404, { error: 'Kein Dev-APK-Download eingerichtet.' }); + return await download(rootFor(ctx.cfg.dataDir), projectFor(config.repo), url.searchParams.get('id'), req, res); + } + if (req.method === 'GET') return send(res, 200, view(ctx.cfg.dataDir, floor.def.repo, session)); + if (req.method !== 'POST') return send(res, 405, { error: 'GET oder POST verwenden.' }); + if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Build im Office starten.' }); + // No command, path, ref, or launch arguments are accepted from a browser. + const job = start(ctx.cfg.dataDir, floor.def.repo, session); return send(res, 202, job); + } catch (error) { + if (res.headersSent) { res.destroy(); return; } + return send(res, error instanceof BuildError ? error.status : 500, { error: error instanceof BuildError ? error.message : 'Dev-APK konnte nicht verarbeitet werden. Serverprotokoll prüfen.' }); + } + }, +}; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..0562c9915 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { devApkRoute } from './dev-apk.js'; export const routes: readonly Route[] = [ // Anyone. @@ -35,6 +36,7 @@ export const routes: readonly Route[] = [ fileRoutes.docs, searchRoutes.search, serviceRoutes.forwards, + devApkRoute, githubRoutes.github, pageRoutes.office, pageRoutes.lite, diff --git a/src/shared/dev-apk.ts b/src/shared/dev-apk.ts new file mode 100644 index 000000000..368f7b721 --- /dev/null +++ b/src/shared/dev-apk.ts @@ -0,0 +1,21 @@ +export interface DevApkBuild { + id: string; + status: 'starting' | 'waiting' | 'building' | 'publishing' | 'ready' | 'failed' | 'interrupted'; + requestedBy: string; + startedAt: number; + finishedAt?: number; + revision?: string; + ref: string; + bytes?: number; + sha256?: string; + error?: string; +} +export interface DevApkView { + configured: boolean; + canBuild: boolean; + ref?: string; + latest?: DevApkBuild; + current?: DevApkBuild; + log?: string; + busy?: boolean; +} diff --git a/tests/dev-apk-browser.mjs b/tests/dev-apk-browser.mjs new file mode 100644 index 000000000..ea4b35874 --- /dev/null +++ b/tests/dev-apk-browser.mjs @@ -0,0 +1,54 @@ +// Uses the real authenticated HTTP routes/store; a broken fixture build verifies failed-build UX. +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createServer } from 'vite'; +import { chromium } from 'playwright-core'; +import { requestHandler } from '../dist/server/server/http/router.js'; +import { devApkRoute } from '../dist/server/server/http/routes/dev-apk.js'; +import { atomic, jobDir, projectFor, rootFor, saveJob } from '../dist/server/server/dev-apk/storage.js'; +import { publish } from '../dist/server/server/dev-apk/runner.js'; +import { view } from '../dist/server/server/dev-apk/manager.js'; +const dir = mkdtempSync(path.join(tmpdir(), 'apk-browser-')), source = path.join(dir, 'source'); +mkdirSync(path.join(source, 'Tools'), { recursive: true }); +writeFileSync(path.join(source, 'Tools/Build-Quest.ps1'), "throw 'Fixture build failed'"); +writeFileSync(path.join(source, 'Tools/BuildLock.ps1'), "throw 'Fixture build failed before Unity starts'"); +const config = { repo: 'owner/game', sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk' }; +atomic(path.join(dir, 'dev-apk.json'), { projects: [config] }); +const root = rootFor(dir), project = projectFor(config.repo); +const job = { id: randomUUID(), project, config, pid: process.pid, status: 'publishing', requestedBy: 'Kajo', startedAt: Date.now(), ref: 'origin/main' }; +saveJob(root, job); const folder = jobDir(root, project, job.id); +writeFileSync(path.join(folder, 'app.apk.part'), Buffer.from('PK\x03\x04fixture APK')); writeFileSync(path.join(folder, 'revision.txt'), 'b'.repeat(40)); await publish(root, job); +const session = { account: { id: 'admin', name: 'Kajo', role: 'admin' } }; +const ctx = { cfg: { dataDir: dir, port: 0, trustProxy: false }, services: { lookup: () => undefined }, auth: { fromRequest: () => session }, floors: new Map([['game', { def: { repo: config.repo } }]]) }; +const handler = requestHandler(ctx, [devApkRoute]); +const vite = await createServer({ configFile: false, root: process.cwd(), server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'apk-acceptance', configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url.startsWith('/api/dev-apk')) return void handler(req, res); + if (req.url !== '/__apk-test') return next(); + res.setHeader('content-type', 'text/html'); res.end(``); + }); +} }] }); +let browser; +try { + await vite.listen(); const origin = `http://127.0.0.1:${vite.httpServer.address().port}`; + browser = await chromium.launch({ executablePath: process.env.APK_TEST_BROWSER || 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }), errors = []; + page.on('pageerror', e => errors.push(e.message)); await page.goto(origin + '/__apk-test'); + await page.getByRole('link', { name: 'APK herunterladen', exact: true }).waitFor(); + const post = page.waitForResponse(r => r.request().method() === 'POST' && r.url().includes('/api/dev-apk')); + await page.getByRole('button', { name: 'Dev-APK bauen', exact: true }).click(); assert.equal((await post).status(), 202); + await page.getByText(/Build fehlgeschlagen/).waitFor({ timeout: 30000 }); + assert.equal(view(dir, config.repo, session).latest.id, job.id); + const download = await page.request.get(new URL(await page.getByRole('link', { name: 'APK herunterladen', exact: true }).getAttribute('href'), origin).href); + assert.equal(download.status(), 200); assert.equal((await download.body()).subarray(0, 4).toString('hex'), '504b0304'); + const output = process.env.APK_SCREENSHOT_DIR || 'E:/source/agent-office-deployment/dev-apk-screenshots'; mkdirSync(output, { recursive: true }); + await page.screenshot({ path: path.join(output, 'desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(output, 'mobile.png'), fullPage: true }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.keyboard.press('Escape'); assert.equal(await page.getByRole('dialog').count(), 0); + await page.evaluate(() => window.show()); await page.getByRole('button', { name: 'Close', exact: true }).click(); assert.equal(await page.getByRole('dialog').count(), 0); + assert.deepEqual(errors, []); console.log('PASS: actual build trigger/failure, previous download, desktop/mobile, Escape and close.'); +} finally { await browser?.close(); await vite.close(); rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/dev-apk.test.ts b/tests/dev-apk.test.ts new file mode 100644 index 000000000..548837e77 --- /dev/null +++ b/tests/dev-apk.test.ts @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import http from 'node:http'; +import { configFor } from '../src/server/dev-apk/config.js'; +import { start, view } from '../src/server/dev-apk/manager.js'; +import { activeJob, atomic, jobDir, projectFor, release, rootFor, saveJob, type Job } from '../src/server/dev-apk/storage.js'; +import { publish } from '../src/server/dev-apk/runner.js'; +import { byteRange } from '../src/server/dev-apk/download.js'; +import { devApkRoute } from '../src/server/http/routes/dev-apk.js'; +import { requestHandler } from '../src/server/http/router.js'; +const repo = 'owner/game', admin = { account: { id: 'admin', name: 'Admin', role: 'admin' } } as any; +function fixture(t: any) { + const dir = mkdtempSync(path.join(tmpdir(), 'office-apk-')); t.after(() => rmSync(dir, { recursive: true, force: true })); + const source = path.join(dir, 'source'); mkdirSync(path.join(source, 'Tools'), { recursive: true }); + for (const file of ['Build-Quest.ps1', 'BuildLock.ps1']) writeFileSync(path.join(source, 'Tools', file), '# test fixture'); + const config = { repo, sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk', builders: ['builder'] }; + atomic(path.join(dir, 'dev-apk.json'), { projects: [config] }); + return { dir, config, root: rootFor(dir), project: projectFor(repo) }; +} +async function ready(f: ReturnType, bytes = Buffer.from('PK\x03\x04first apk'), finishedOffset = 0) { + const job: Job = { id: randomUUID(), project: f.project, config: f.config, pid: process.pid, requestedBy: 'Tester', ref: 'origin/main', startedAt: Date.now(), status: 'publishing' }; + saveJob(f.root, job); const folder = jobDir(f.root, f.project, job.id); + writeFileSync(path.join(folder, 'app.apk.part'), bytes); writeFileSync(path.join(folder, 'revision.txt'), 'a'.repeat(40)); + await publish(f.root, job); job.finishedAt! += finishedOffset; saveJob(f.root, job); return job; +} +test('builds use local config only, enforce permission and reject duplicate starts', t => { + const f = fixture(t); const member = { account: { id: 'member', name: 'Tester', role: 'member' } } as any; + assert.equal(view(f.dir, repo, member).canBuild, false); + assert.throws(() => start(f.dir, repo, member, () => {}), /freigeschaltete/); + assert.equal(view(f.dir, repo, { account: { ...member.account, id: 'builder' } }).canBuild, true); + let launched: Job | undefined; + const j = start(f.dir, repo, admin, (_, job) => { launched = job; })!; + assert.equal(launched?.config.ref, 'origin/main'); assert.equal(view(f.dir, repo, admin).busy, true); + assert.throws(() => start(f.dir, repo, admin, () => {}), /läuft bereits/); + assert.equal(readFileSync(path.join(f.config.sourceDir, 'Tools/Build-Quest.ps1'), 'utf8'), '# test fixture'); + release(f.root, j.id); +}); +test('unsafe config cannot designate a parent of the source checkout as disposable build folder', t => { + const f = fixture(t); + atomic(path.join(f.dir, 'dev-apk.json'), { projects: [{ ...f.config, buildDir: f.dir }] }); + assert.throws(() => configFor(f.dir, repo), /nicht enthalten/); + atomic(path.join(f.dir, 'dev-apk.json'), { projects: [{ ...f.config, ref: 'origin/main; bad' }] }); + assert.throws(() => configFor(f.dir, repo), /Konfiguration prüfen/); +}); +test('previous download survives invalid or failed publication, exact artifact gets hash and commit', async t => { + const f = fixture(t), good = await ready(f); + const before = view(f.dir, repo, admin).latest!; assert.equal(before.id, good.id); assert.equal(before.sha256?.length, 64); + await assert.rejects(ready(f, Buffer.from('not an apk')), /keine APK/); + assert.equal(view(f.dir, repo, admin).latest?.id, good.id); + assert.equal(view(f.dir, repo, admin).latest?.revision, 'a'.repeat(40)); +}); +test('orphaned job is interrupted after restart and does not delete last successful APK', async t => { + const f = fixture(t), good = await ready(f); const j = start(f.dir, repo, admin, () => {})!; + atomic(path.join(f.root, 'active.json'), { id: j.id, project: f.project, pid: 2147483647 }); + assert.equal(activeJob(f.root), undefined); + assert.equal(view(f.dir, repo, admin).current?.status, 'interrupted'); + assert.equal(view(f.dir, repo, admin).latest?.id, good.id); +}); +test('range parser handles suffixes, open ends, overflow and invalid multi-ranges', () => { + assert.deepEqual(byteRange('bytes=2-5', 10), { start: 2, end: 5 }); + assert.deepEqual(byteRange('bytes=7-', 10), { start: 7, end: 9 }); + assert.deepEqual(byteRange('bytes=-3', 10), { start: 7, end: 9 }); + assert.deepEqual(byteRange('bytes=-300', 10), { start: 0, end: 9 }); + for (const h of ['bytes=-0', 'bytes=10-', 'bytes=3-2', 'bytes=0-1,5-6', 'bytes=999999999999999999-']) assert.equal(byteRange(h, 10), undefined); +}); +test('retention keeps three successful APKs and never deletes the latest', async t => { + const f = fixture(t); const jobs: Job[] = []; + for (let i = 0; i < 4; i++) jobs.push(await ready(f, undefined, i * 1000)); + assert.equal(existsSync(path.join(jobDir(f.root, f.project, jobs[0].id), 'app.apk')), false); + assert.equal(existsSync(path.join(jobDir(f.root, f.project, jobs[3].id), 'app.apk')), true); +}); +test('HTTP download requires office login, streams ranges and pins versions while latest changes', async t => { + const f = fixture(t), first = await ready(f); + const member = { account: { id: 'member', name: 'Tester', role: 'member' } }; + const ctx = { cfg: { dataDir: f.dir, port: 0, trustProxy: false }, services: { lookup: () => undefined }, + auth: { fromRequest: (req: http.IncomingMessage) => req.headers.cookie === 'signed-in' ? member : undefined }, + floors: new Map([['game', { def: { repo } }]]) } as any; + const server = http.createServer(requestHandler(ctx, [devApkRoute])); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const origin = `http://127.0.0.1:${(server.address() as any).port}`, url = origin + '/api/dev-apk/download?floor=game', headers = { cookie: 'signed-in' }; + assert.equal((await fetch(url)).status, 401); + assert.equal((await fetch(origin + '/api/dev-apk?floor=game', { method: 'POST', headers })).status, 403); + assert.equal((await fetch(origin + '/api/dev-apk?floor=game', { method: 'POST', headers: { ...headers, origin } })).status, 403); + const response = await fetch(url, { headers }); assert.equal(response.status, 200); const etag = response.headers.get('etag')!; + assert.equal(Buffer.from(await response.arrayBuffer()).toString(), 'PK\x03\x04first apk'); + const partial = await fetch(url, { headers: { ...headers, range: 'bytes=4-8', 'if-range': etag } }); assert.equal(partial.status, 206); assert.equal(await partial.text(), 'first'); + assert.equal((await fetch(url, { headers: { ...headers, range: 'bytes=99999-' } })).status, 416); + const head = await fetch(url, { method: 'HEAD', headers }); assert.equal(head.status, 200); assert.equal(await head.text(), ''); assert.equal(head.headers.get('content-length'), String(first.bytes)); + await ready(f, Buffer.from('PK\x03\x04second apk')); + const changed = await fetch(url, { headers: { ...headers, range: 'bytes=4-8', 'if-range': etag } }); assert.equal(changed.status, 200); await changed.arrayBuffer(); + const pinned = await fetch(url + '&id=' + first.id, { headers }); assert.equal(await pinned.text(), 'PK\x03\x04first apk'); + assert.equal((await fetch(url + '&id=../../escape', { headers })).status, 404); + assert.equal((await fetch(origin + '/api/dev-apk?floor=missing', { headers })).status, 404); +}); From acb2bf668618e423c8a40313fb38ad0df9434e45 Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Sun, 11 Oct 2026 20:46:01 +0200 Subject: [PATCH 2/3] Add separate Store APK builds and rename APK menu --- README.md | 2 +- docs/dev-apk.md | 13 ++++-- src/client/ui/dev-apk/index.ts | 44 +++++++++++------- src/client/ui/dev-apk/ui.css | 1 + src/server/dev-apk/config.ts | 8 ++++ src/server/dev-apk/download.ts | 11 ++--- src/server/dev-apk/manager.ts | 27 ++++++----- src/server/dev-apk/powershell.ts | 12 ++++- src/server/dev-apk/runner.ts | 10 ++--- src/server/dev-apk/storage.ts | 7 ++- src/server/http/routes/dev-apk.ts | 13 +++--- src/shared/dev-apk.ts | 2 + tests/dev-apk-browser.mjs | 19 +++++++- tests/dev-apk.test.ts | 75 +++++++++++++++++++++++++++++-- 14 files changed, 184 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index f854f5ad8..8eba45fb9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # 🏢 Agent Office -Dev-APKs für entfernte Tester: **☰ → Dev-APK** startet den eingerichteten Quest-Development-Build und bietet die neueste erfolgreiche APK hinter dem Office-Login zum Download an, auch über den bestehenden HTTPS-Tunnel. [Einrichtung und Bedienung](docs/dev-apk.md). +APKs für entfernte Tester: **☰ → APK** bietet getrennte Quest-Development- und signierte Store-Builds. Beide Varianten haben ihren eigenen neuesten erfolgreichen Download hinter dem Office-Login, auch über den bestehenden HTTPS-Tunnel. [Einrichtung und Bedienung](docs/dev-apk.md). **A 3D office your team shares with its coding agents.** diff --git a/docs/dev-apk.md b/docs/dev-apk.md index d67fa0d11..5ed9f2d9d 100644 --- a/docs/dev-apk.md +++ b/docs/dev-apk.md @@ -1,10 +1,10 @@ -# Dev-APK bauen und herunterladen +# APK bauen und herunterladen -Im Office: **☰ → Dev-APK**. Der Eintrag lässt sich oben anheften. Er gehört zum gesamten Projekt, nicht zu einem einzelnen Playtest. Der Tester braucht zuhause kein Unity. +Im Office: **☰ → APK**. Über **Dev-APK** oder **Store-APK** wählst du die Variante. Jede hat ihren eigenen Build-Button, letzten erfolgreichen Download und teilbaren Link. Bestehende Pins bleiben gültig. Der Eintrag lässt sich oben anheften. Er gehört zum gesamten Projekt, nicht zu einem einzelnen Playtest. Der Tester braucht zuhause kein Unity. ## Ablauf -1. Ein freigeschalteter Nutzer drückt **Dev-APK bauen**. Der Server startet `Tools/Build-Quest.ps1 -Development` auf dem Build-PC. +1. Ein freigeschalteter Nutzer drückt **Dev-APK bauen** oder **Store-APK bauen**. Der Server startet `Tools/Build-Quest.ps1 -Development` beziehungsweise `-Store` auf dem Build-PC. Store nutzt den vorhandenen Release-Ablauf einschließlich Signierung und Versionsnummer. Der Button veröffentlicht nichts bei Meta. 2. Läuft bereits ein Unity-Build oder Test, wartet der Auftrag auf denselben globalen Build-Platz. Status und Protokoll sind sichtbar. Ein zweiter Office-Build wird währenddessen abgelehnt. 3. Der konfigurierte Branch wird frisch abgerufen und auf einen Commit festgelegt. Der gemeinsame Arbeitscheckout wird weder zurückgesetzt noch umgeschaltet. Gebaut wird im vorbereiteten Build-Ordner. 4. Erst nach erfolgreichem Build, vollständiger Kopie und SHA-256-Berechnung wird der Download veröffentlicht. Ein Fehlschlag lässt die letzte erfolgreiche APK unverändert. @@ -24,6 +24,7 @@ Im Office-Datenordner (`/.agent-office`, passend zu `--home`) die D "buildDir": "E:/source/Unity/zrb-build", "ref": "origin/main", "apkName": "ZombieRealityBreach-dev.apk", + "storeApkName": "ZombieRealityBreach.apk", "launcher": "C:/Users/gp/KaMaStudios/tools/unity-hidden.ps1", "builders": [] }] @@ -32,6 +33,8 @@ Im Office-Datenordner (`/.agent-office`, passend zu `--home`) die D Voraussetzungen: Node, Git, Windows PowerShell, Unity/Android und der funktionierende ZRB-Build-Ablauf inklusive `Tools/BuildLock.ps1`. `sourceDir` ist ein vertrauenswürdiger Projektcheckout mit den erforderlichen Assets; `buildDir` ist ausschließlich ein wegwerfbarer Build-Checkout, niemals ein Arbeitscheckout. Der Office-Prozess muss Zugriff darauf haben. Der vorhandene Build-Ablauf kopiert seine Ausgabe nach `sourceDir/Builds/Quest`; die Download-Kopie wird noch unter demselben Build-Mutex erstellt. +Für Store muss `storeApkName` den vom Build-Script erzeugten Release-Dateinamen angeben; er muss sich vom Dev-Dateinamen unterscheiden. Fehlt das Feld, bleibt Dev nutzbar und Store-Bauen deaktiviert. Die Release-Signierung bleibt ausschließlich auf dem Build-PC in der vom Spiel verwendeten Signing-Konfiguration. Keine Schlüssel oder Passwörter in die Office-Konfiguration eintragen. Die ZRB-Release-Versionsnummer kommt derzeit aus `yyMMddHH`; mehrere Builds innerhalb derselben Stunde erhalten dieselbe Nummer und sind daher nicht automatisch aufeinanderfolgende Meta-Uploads. + Admins und Nutzer des gemeinsamen Office-Passworts dürfen Builds starten. Für weitere Co-Devs deren **Office-Konto-ID** in `builders` eintragen. Alle angemeldeten Office-Nutzer dürfen herunterladen. Keine Issues oder Worker/Queue-Einträge werden erzeugt. Der Branch ist in der Oberfläche sichtbar. Für XR-Playtest-Werkzeuge muss der gewählte Stand den Spiel-PR #361 enthalten. Ein Development-Build fügt keine noch nicht integrierten Änderungen hinzu. @@ -40,6 +43,8 @@ Der Branch ist in der Oberfläche sichtbar. Für XR-Playtest-Werkzeuge muss der Die APK liegt unter derselben Office-Adresse: beispielsweise `https://studios.zombierealitybreach.com/api/dev-apk/download?floor=`. Der vorhandene HTTPS-/SSH-Tunnel transportiert den authentifizierten HTTP-Download. Kein zusätzlicher Port oder lokaler `agent-office tunnel`-Helfer ist nötig. +Die vorhandenen API-Pfade und der Datenordner behalten zur Kompatibilität ihren Namen `dev-apk`. Für Store an Status-, Build- und Download-URL `&kind=store` anhängen. Ohne `kind` oder mit `kind=development` gilt weiterhin Dev, auch für ältere Jobs und Links. Eine Store-Anfrage liefert niemals ersatzweise die Dev-APK. + GET/HEAD, Dateigröße, ETag, Range und If-Range ermöglichen fortsetzbare Downloads. Dateien werden gestreamt. Versionierte Links bleiben während neuer Builds unverändert; If-Range verhindert beim Fortsetzen des „neueste“-Links das Vermischen verschiedener Versionen. Der Proxy muss Cookies und Range-Header weiterleiten, die API darf nicht öffentlich gecacht werden. `X-Accel-Buffering: no` wird gesendet. Bei nginx gegebenenfalls `proxy_buffering off` und ausreichend langen `proxy_read_timeout` für den Download-Pfad konfigurieren. `client_max_body_size` begrenzt Uploads, nicht diese Downloads. Build-PC und Tunnel müssen online bleiben. @@ -54,6 +59,6 @@ Meta verlangt auch für Alpha/Beta/RC die Release-Verpackungsregeln. Für unsere ## Speicherung und Prüfung -Unter `/dev-apk/` liegen Aufträge, Protokolle und APKs. Die letzten drei erfolgreichen APKs bleiben erhalten. Ältere APK-Dateien werden nach erfolgreicher Veröffentlichung entfernt; Metadaten und Logs bleiben bestehen. Partielle oder fehlgeschlagene Builds ersetzen niemals den neuesten erfolgreichen Download. +Unter `/dev-apk/` liegen Aufträge, Protokolle und APKs. Pro Variante bleiben die letzten drei erfolgreichen APKs erhalten. Store-Veröffentlichungen löschen keine Dev-APKs und umgekehrt. Ältere APK-Dateien werden nach erfolgreicher Veröffentlichung entfernt; Metadaten und Logs bleiben bestehen. Partielle oder fehlgeschlagene Builds ersetzen niemals den neuesten erfolgreichen Download. Prüfung: `npm run typecheck`, `npm test`, `npm run build`; gezielt `node --import tsx --test tests/dev-apk.test.ts tests/size.test.ts`; Browser-Abnahme `node --import tsx tests/dev-apk-browser.mjs`. HTTP-/Browser-Tests mit Testdateien sind getrennt von echten Unity-Builds, öffentlichen Tunnel-Downloads und Headset-Installation zu berichten. diff --git a/src/client/ui/dev-apk/index.ts b/src/client/ui/dev-apk/index.ts index 911c21977..c991742ee 100644 --- a/src/client/ui/dev-apk/index.ts +++ b/src/client/ui/dev-apk/index.ts @@ -2,41 +2,51 @@ import './ui.css'; import { h, openModal, type Modal } from '../dom'; import { store } from '../../state'; import type { HudAction } from '../menu'; -import type { DevApkBuild, DevApkView } from '../../../shared/dev-apk'; -const labels: Record = { starting: 'Build wird gestartet', waiting: 'Warte auf freien Build-Platz', building: 'Dev-APK wird gebaut', publishing: 'Download wird vorbereitet', ready: 'Build erfolgreich', failed: 'Build fehlgeschlagen', interrupted: 'Build unterbrochen' }; +import type { ApkKind, DevApkBuild, DevApkView } from '../../../shared/dev-apk'; +const labels: Record = { starting: 'Build wird gestartet', waiting: 'Warte auf freien Build-Platz', building: 'APK wird gebaut', publishing: 'Download wird vorbereitet', ready: 'Build erfolgreich', failed: 'Build fehlgeschlagen', interrupted: 'Build unterbrochen' }; let modal: Modal | undefined; -export const devApkAction: HudAction = { id: 'dev-apk', icon: '📦', label: 'Dev-APK', section: 'Open', run: () => openDevApk() }; +export const devApkAction: HudAction = { id: 'dev-apk', icon: '📦', label: 'APK', section: 'Open', run: () => openDevApk() }; export function openDevApk() { if (modal) return; const floor = store.floor; if (!floor) return; - const url = '/api/dev-apk?floor=' + encodeURIComponent(floor); + let kind: ApkKind = 'development', request = 0; + const name = () => kind === 'store' ? 'Store-APK' : 'Dev-APK'; + const url = () => '/api/dev-apk?floor=' + encodeURIComponent(floor) + '&kind=' + kind; const status = h('div.dev-apk-status', { role: 'status' }, 'Lade Build-Status…'); const latest = h('section.dev-apk-card'), current = h('section.dev-apk-card'); const log = h('pre.dev-apk-log'), error = h('p.dev-apk-error', { role: 'alert' }); const logDetails = h('details', {}, h('summary', {}, 'Build-Protokoll'), log); let stopped = false, pending = false; + const choices = (['development', 'store'] as const).map(value => h('button.btn', { type: 'button', 'aria-pressed': String(kind === value), onclick: () => { + if (pending || kind === value) return; + kind = value; choices.forEach((button, i) => button.setAttribute('aria-pressed', String(i === (kind === 'store' ? 1 : 0)))); + latest.replaceChildren(); current.replaceChildren(); log.textContent = ''; error.textContent = ''; status.textContent = 'Lade Build-Status…'; startButton.disabled = true; void refresh(); + } }, value === 'store' ? 'Store-APK' : 'Dev-APK')); const startButton = h('button.btn.primary', { type: 'button', disabled: true, onclick: async () => { - pending = true; startButton.disabled = true; error.textContent = ''; - try { const response = await fetch(url, { method: 'POST' }); const result = await response.json(); if (!response.ok) throw Error(result.error); } + pending = true; startButton.disabled = true; choices.forEach(b => { b.disabled = true; }); error.textContent = ''; + try { const response = await fetch(url(), { method: 'POST' }); const result = await response.json(); if (!response.ok) throw Error(result.error); } catch (e) { error.textContent = (e as Error).message; } - finally { pending = false; await refresh(); } + finally { pending = false; choices.forEach(b => { b.disabled = false; }); await refresh(); } } }, 'Dev-APK bauen'); - const link = (id?: string) => '/api/dev-apk/download?floor=' + encodeURIComponent(floor) + (id ? '&id=' + encodeURIComponent(id) : ''); + const link = (id?: string) => '/api/dev-apk/download?floor=' + encodeURIComponent(floor) + '&kind=' + kind + (id ? '&id=' + encodeURIComponent(id) : ''); const copyLink = h('button.btn', { type: 'button', onclick: async () => { try { await navigator.clipboard.writeText(new URL(link(), location.href).href); error.textContent = 'Download-Link kopiert. Der Empfänger benötigt seinen Office-Login.'; } catch { error.textContent = new URL(link(), location.href).href; } } }, 'Download-Link kopieren'); async function refresh() { if (stopped) return; + const selected = kind, sequence = ++request; try { - const response = await fetch(url); const data: DevApkView & { error?: string } = await response.json(); if (!response.ok) throw Error(data.error); - if (stopped) return; + const response = await fetch(url()); const data: DevApkView & { error?: string } = await response.json(); if (!response.ok) throw Error(data.error); + if (stopped || selected !== kind || sequence !== request) return; + startButton.textContent = name() + ' bauen'; startButton.disabled = pending || !data.canBuild || !!data.busy; - status.textContent = !data.configured ? 'Für dieses Projekt ist noch kein Build-Rechner eingerichtet.' : data.busy ? (data.current && !['ready', 'failed', 'interrupted'].includes(data.current.status) ? labels[data.current.status] : 'Ein anderes Projekt wird gerade gebaut.') : 'Bereit'; - current.replaceChildren(h('div', {}, h('h3', {}, 'Neuen Dev-Build erstellen'), h('p', {}, data.configured ? `Quelle: ${data.ref}. Der Server baut den neuesten Stand dieses Branches.` : 'Der Office-Administrator richtet den Build-Ablauf einmal auf dem Server ein.'), startButton, + status.textContent = !data.configured ? `Für ${name()} ist noch kein Build-Ablauf eingerichtet.` : data.busy ? (data.current && !['ready', 'failed', 'interrupted'].includes(data.current.status) ? labels[data.current.status] : 'Eine andere APK wird gerade gebaut.') : 'Bereit'; + current.replaceChildren(h('div', {}, h('h3', {}, name() + ' erstellen'), h('p', {}, data.configured ? `Quelle: ${data.ref}. Der Server baut den neuesten Stand dieses Branches.` : 'Der Office-Administrator richtet den Build-Ablauf einmal auf dem Server ein.'), + h('p', {}, kind === 'store' ? 'Signierter Release-Build mit der Versionsnummer des Store-Build-Ablaufs. Ein Upload zu Meta erfolgt separat.' : 'Development-Build für Debugging und Playtests.'), startButton, data.configured && !data.canBuild ? h('p', {}, 'Du kannst APKs herunterladen. Für Build-Starts muss dich ein Administrator freischalten.') : null, data.current ? h('p', {}, `${labels[data.current.status]} · ${new Date(data.current.startedAt).toLocaleString()} · ${data.current.requestedBy}`, data.current.error ? h('span', {}, ' — ' + data.current.error) : null) : null)); - latest.replaceChildren(h('h3', {}, 'Neueste erfolgreiche Dev-APK')); + latest.replaceChildren(h('h3', {}, 'Neueste erfolgreiche ' + name())); if (data.latest) { const apk = data.latest; latest.append(h('p', {}, `${new Date(apk.finishedAt!).toLocaleString()} · ${Math.round(apk.bytes! / 1048576)} MB · Commit ${apk.revision?.slice(0, 12)}`), @@ -44,11 +54,11 @@ export function openDevApk() { h('details', {}, h('summary', {}, 'SHA-256 prüfen'), h('code', {}, apk.sha256 ?? ''))); } else latest.append(h('p', {}, 'Noch keine APK vorhanden. Nach dem ersten erfolgreichen Build erscheint hier der Download.')); log.textContent = data.log || 'Noch kein Build-Protokoll.'; - } catch (e) { if (!stopped) { error.textContent = (e as Error).message; startButton.disabled = true; } } + } catch (e) { if (!stopped && selected === kind && sequence === request) { error.textContent = (e as Error).message; startButton.disabled = true; } } } - modal = openModal(h('section.dev-apk-dialog', { role: 'dialog', 'aria-label': 'Dev-APK', 'aria-modal': 'true' }, - h('header', {}, h('h2', {}, '📦 Dev-APK')), h('div.dev-apk-body', {}, - h('p', {}, 'Dev-Versionen bauen und mit deinem Testteam teilen. Unity wird nur auf dem Build-Rechner benötigt.'), status, latest, current, error, logDetails, + modal = openModal(h('section.dev-apk-dialog', { role: 'dialog', 'aria-label': 'APK', 'aria-modal': 'true' }, + h('header', {}, h('h2', {}, '📦 APK')), h('div.dev-apk-body', {}, + h('p', {}, 'Dev- und Store-Versionen bauen und herunterladen. Unity wird nur auf dem Build-Rechner benötigt.'), h('div.dev-apk-actions', { role: 'group', 'aria-label': 'APK-Variante' }, ...choices), status, latest, current, error, logDetails, h('details', {}, h('summary', {}, 'Auf der Quest installieren'), h('p', {}, 'APK auf dem eigenen PC herunterladen. Quest im Entwicklermodus per USB verbinden und die Datei mit Meta Quest Developer Hub oder ADB installieren. Unity ist dafür nicht nötig.'), h('p', {}, 'Bei unterschiedlicher Signatur zur Store-Version ist kein direktes Update möglich. Eine vorhandene App nicht ohne Sicherung ihrer Daten deinstallieren.')))), { onClose: () => { stopped = true; clearInterval(timer); off(); modal = undefined; } }); const timer = setInterval(() => { void refresh(); }, 2500); diff --git a/src/client/ui/dev-apk/ui.css b/src/client/ui/dev-apk/ui.css index 847fc3594..244595fb2 100644 --- a/src/client/ui/dev-apk/ui.css +++ b/src/client/ui/dev-apk/ui.css @@ -8,6 +8,7 @@ .dev-apk-card h3 { margin: 0 0 14px; } .dev-apk-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .dev-apk-dialog .btn { white-space: normal; text-decoration: none; } +.dev-apk-dialog [aria-pressed="true"] { background: #ff895e; } .dev-apk-dialog code { overflow-wrap: anywhere; } .dev-apk-dialog summary { cursor: pointer; } .dev-apk-log { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow: auto; font-size: 12px; background: #222231; color: #f8f8ff; padding: 12px; } diff --git a/src/server/dev-apk/config.ts b/src/server/dev-apk/config.ts index f9e267ead..d7f798f0f 100644 --- a/src/server/dev-apk/config.ts +++ b/src/server/dev-apk/config.ts @@ -1,15 +1,22 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; +import type { ApkKind } from '../../shared/dev-apk.js'; export interface DevApkConfig { repo: string; sourceDir: string; buildDir: string; ref: string; apkName: string; + storeApkName?: string; launcher?: string; builders?: string[]; } export class BuildError extends Error { constructor(message: string, public status = 400) { super(message); } } +export function parseKind(value: string | null): ApkKind { + if (value === null || value === 'development') return 'development'; + if (value === 'store') return 'store'; + throw new BuildError('Unbekannte APK-Auswahl.'); +} export function configFor(dataDir: string, repo: string | undefined): DevApkConfig | undefined { const file = path.join(dataDir, 'dev-apk.json'); if (!repo || !existsSync(file)) return; @@ -20,6 +27,7 @@ export function configFor(dataDir: string, repo: string | undefined): DevApkConf if (!path.isAbsolute(c.sourceDir ?? '') || !path.isAbsolute(c.buildDir ?? '') || !/^origin\/[a-zA-Z0-9][a-zA-Z0-9._/-]{0,180}$/.test(c.ref ?? '') || c.ref.includes('..') || !/^[a-zA-Z0-9_-]+\.apk$/.test(c.apkName ?? '') || (c.launcher && !path.isAbsolute(c.launcher)) || + (c.storeApkName !== undefined && (!/^[a-zA-Z0-9_-]+\.apk$/.test(c.storeApkName) || c.storeApkName.toLowerCase() === c.apkName.toLowerCase())) || (c.builders !== undefined && (!Array.isArray(c.builders) || c.builders.some((b: unknown) => typeof b !== 'string')))) throw new BuildError('Dev-APK-Konfiguration prüfen: absolute Ordner und gültiger origin-Branch erforderlich.', 503); const source = path.resolve(c.sourceDir), build = path.resolve(c.buildDir); const relative = path.relative(build, source); diff --git a/src/server/dev-apk/download.ts b/src/server/dev-apk/download.ts index 27cbc5860..f48618354 100644 --- a/src/server/dev-apk/download.ts +++ b/src/server/dev-apk/download.ts @@ -2,7 +2,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { open } from 'node:fs/promises'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; -import { jobDir, readJson, type Job } from './storage.js'; +import { kindOf, jobDir, readJson, type Job } from './storage.js'; +import type { ApkKind } from '../../shared/dev-apk.js'; import { latest } from './manager.js'; import { BuildError } from './config.js'; export function byteRange(header: string, size: number): { start: number; end: number } | undefined { @@ -13,9 +14,9 @@ export function byteRange(header: string, size: number): { start: number; end: n const start = match[1] ? a : Math.max(0, size - b), end = match[1] && match[2] ? Math.min(b, size - 1) : size - 1; return start <= end && start < size ? { start, end } : undefined; } -export async function download(root: string, project: string, id: string | null, req: IncomingMessage, res: ServerResponse) { - const job = id ? readJson(path.join(jobDir(root, project, id), 'job.json')) : latest(root, project); - if (!job || job.status !== 'ready' || !job.sha256 || !job.bytes) throw new BuildError('Noch keine erfolgreiche APK vorhanden.', 404); +export async function download(root: string, project: string, id: string | null, req: IncomingMessage, res: ServerResponse, kind: ApkKind = 'development') { + const job = id ? readJson(path.join(jobDir(root, project, id), 'job.json')) : latest(root, project, kind); + if (!job || kindOf(job) !== kind || job.status !== 'ready' || !job.sha256 || !job.bytes) throw new BuildError('Noch keine erfolgreiche APK dieser Variante vorhanden.', 404); const file = await open(path.join(jobDir(root, project, job.id), 'app.apk'), 'r').catch(() => { throw new BuildError('Dieser ältere Download ist nicht mehr vorhanden.', 410); }); try { const stat = await file.stat(); if (stat.size !== job.bytes) throw new BuildError('APK-Prüfung fehlgeschlagen.', 503); @@ -27,7 +28,7 @@ export async function download(root: string, project: string, id: string | null, if (!range) { res.writeHead(416, { 'content-range': `bytes */${stat.size}` }).end(); return; } } const headers: Record = { 'content-type': 'application/vnd.android.package-archive', - 'content-disposition': `attachment; filename="dev-${job.revision?.slice(0, 12) ?? job.id}.apk"`, + 'content-disposition': `attachment; filename="${kind === 'store' ? 'store' : 'dev'}-${job.revision?.slice(0, 12) ?? job.id}.apk"`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-accel-buffering': 'no', 'accept-ranges': 'bytes', etag, 'last-modified': modified, 'content-length': String(range ? range.end - range.start + 1 : stat.size) }; diff --git a/src/server/dev-apk/manager.ts b/src/server/dev-apk/manager.ts index e80b08256..6becd056a 100644 --- a/src/server/dev-apk/manager.ts +++ b/src/server/dev-apk/manager.ts @@ -4,13 +4,14 @@ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } fro import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Session } from '../auth.js'; -import type { DevApkView } from '../../shared/dev-apk.js'; +import type { ApkKind, DevApkView } from '../../shared/dev-apk.js'; import { BuildError, configFor, type DevApkConfig } from './config.js'; -import { activeJob, atomic, jobDir, projectFor, publicJob, readJson, release, reserve, rootFor, saveJob, type Job } from './storage.js'; +import { activeJob, atomic, indexFile, kindOf, jobDir, projectFor, publicJob, readJson, release, reserve, rootFor, saveJob, type Job } from './storage.js'; export const canBuild = (s: Session, c: DevApkConfig) => !s.account || s.account.role === 'admin' || !!c.builders?.includes(s.account.id); -export function latest(root: string, project: string) { - const index = readJson<{ id: string }>(path.join(root, project, 'latest.json')); - return index ? readJson(path.join(jobDir(root, project, index.id), 'job.json')) : undefined; +export function latest(root: string, project: string, kind: ApkKind = 'development') { + const index = readJson<{ id: string }>(path.join(root, project, indexFile('latest', kind))); + const job = index ? readJson(path.join(jobDir(root, project, index.id), 'job.json')) : undefined; + return job && kindOf(job) === kind ? job : undefined; } function tail(file: string) { if (!existsSync(file)) return ''; @@ -18,10 +19,11 @@ function tail(file: string) { try { const length = statSync(file).size, buffer = Buffer.alloc(Math.min(length, 16000)); readSync(fd, buffer, 0, buffer.length, length - buffer.length); return buffer.toString('utf8'); } finally { closeSync(fd); } } -export function view(dataDir: string, repo: string | undefined, session: Session): DevApkView { +export function view(dataDir: string, repo: string | undefined, session: Session, kind: ApkKind = 'development'): DevApkView { const c = configFor(dataDir, repo); if (!c) return { configured: false, canBuild: false }; + const configured = kind !== 'store' || !!c.storeApkName; const root = rootFor(dataDir), project = projectFor(c.repo), active = activeJob(root); - const recent = readJson<{ id: string }>(path.join(root, project, 'recent.json')); + const recent = readJson<{ id: string }>(path.join(root, project, indexFile('recent', kind))); const current = recent && readJson(path.join(jobDir(root, project, recent.id), 'job.json')); if (current && ['waiting', 'starting'].includes(current.status)) { const phase = path.join(jobDir(root, project, current.id), 'phase.txt'); @@ -31,18 +33,19 @@ export function view(dataDir: string, repo: string | undefined, session: Session const revision = path.join(jobDir(root, project, current.id), 'revision.txt'); if (existsSync(revision)) current.revision = readFileSync(revision, 'utf8').trim().replace(/^\uFEFF/, ''); } - return { configured: true, canBuild: canBuild(session, c), ref: c.ref, latest: publicJob(latest(root, project)), current: publicJob(current), busy: !!active, + return { configured, canBuild: configured && canBuild(session, c), ref: c.ref, latest: publicJob(latest(root, project, kind)), current: publicJob(current), busy: !!active, log: current ? tail(path.join(jobDir(root, project, current.id), 'build.log')) : '' }; } -export function start(dataDir: string, repo: string | undefined, session: Session, launch = launchRunner) { - const config = configFor(dataDir, repo); if (!config) throw new BuildError('Für dieses Projekt ist noch kein Dev-Build eingerichtet.', 404); +export function start(dataDir: string, repo: string | undefined, session: Session, launch = launchRunner, kind: ApkKind = 'development') { + const config = configFor(dataDir, repo); if (!config) throw new BuildError('Für dieses Projekt ist noch kein APK-Build eingerichtet.', 404); if (!canBuild(session, config)) throw new BuildError('Nur freigeschaltete Build-Nutzer dürfen einen Build starten.', 403); + if (kind === 'store' && !config.storeApkName) throw new BuildError('Store-APK-Dateiname muss auf dem Build-Server eingerichtet werden.', 503); if (!existsSync(path.join(config.sourceDir, 'Tools', 'Build-Quest.ps1')) || !existsSync(path.join(config.sourceDir, 'Tools', 'BuildLock.ps1'))) throw new BuildError('Der konfigurierte Quest-Build-Ablauf fehlt auf dem Server.', 503); const root = rootFor(dataDir), project = projectFor(config.repo); - const job: Job = { id: randomUUID(), project, config, ref: config.ref, pid: process.pid, status: 'starting', startedAt: Date.now(), requestedBy: session.account?.name ?? 'Office' }; + const job: Job = { id: randomUUID(), kind, project, config, ref: config.ref, pid: process.pid, status: 'starting', startedAt: Date.now(), requestedBy: session.account?.name ?? 'Office' }; reserve(root, job); try { - saveJob(root, job); atomic(path.join(root, project, 'recent.json'), { id: job.id }); + saveJob(root, job); atomic(path.join(root, project, indexFile('recent', kind)), { id: job.id }); launch(root, job); } catch (e) { job.status = 'failed'; job.error = (e as Error).message; job.finishedAt = Date.now(); saveJob(root, job); release(root, job.id); throw e; } return publicJob(job); diff --git a/src/server/dev-apk/powershell.ts b/src/server/dev-apk/powershell.ts index 377aa65cf..5559a6968 100644 --- a/src/server/dev-apk/powershell.ts +++ b/src/server/dev-apk/powershell.ts @@ -20,11 +20,19 @@ try { Set-Content -LiteralPath (Join-Path $folder 'revision.txt') -Value $revision Write-Output ('Baue Commit ' + $revision) $started = [DateTime]::UtcNow - $arguments = @{ Development = $true; Ref = $revision; Main = $source; Folder = $job.config.buildDir } + $arguments = @{ Ref = $revision; Main = $source; Folder = $job.config.buildDir } + if ($job.kind -eq 'store') { + if (-not $job.config.storeApkName) { throw 'Store-APK-Dateiname fehlt.' } + $arguments.Store = $true + $apkName = $job.config.storeApkName + } elseif (-not $job.kind -or $job.kind -eq 'development') { + $arguments.Development = $true + $apkName = $job.config.apkName + } else { throw 'Unbekannte APK-Auswahl.' } if ($job.config.launcher) { $arguments.Launcher = $job.config.launcher } & $script @arguments if ($LASTEXITCODE -ne 0) { throw 'Build-Script fehlgeschlagen.' } - $apk = Join-Path $source ('Builds\Quest\' + $job.config.apkName) + $apk = Join-Path $source ('Builds\Quest\' + $apkName) $file = Get-Item -LiteralPath $apk if ($file.Length -lt 4 -or $file.LastWriteTimeUtc -lt $started.AddSeconds(-2)) { throw 'Keine neue APK erzeugt. Vorherige APK wird nicht als neuer Build veröffentlicht.' } Set-Content -LiteralPath (Join-Path $folder 'phase.txt') -Value 'publishing' diff --git a/src/server/dev-apk/runner.ts b/src/server/dev-apk/runner.ts index 222dab0c2..be15444a6 100644 --- a/src/server/dev-apk/runner.ts +++ b/src/server/dev-apk/runner.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { closeSync, createReadStream, existsSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { atomic, jobDir, readJson, release, saveJob, type Job } from './storage.js'; +import { atomic, indexFile, kindOf, jobDir, readJson, release, saveJob, type Job } from './storage.js'; import { buildScript } from './powershell.js'; export async function publish(root: string, job: Job) { const dir = jobDir(root, job.project, job.id), part = path.join(dir, 'app.apk.part'); @@ -19,10 +19,10 @@ export async function publish(root: string, job: Job) { if (!/^[a-f0-9]{40}$/.test(job.revision)) throw new Error('Build-Revision fehlt.'); renameSync(part, path.join(dir, 'app.apk')); job.status = 'ready'; job.finishedAt = Date.now(); saveJob(root, job); - atomic(path.join(root, job.project, 'latest.json'), { id: job.id }); - // Keep the last three successful APKs. Never unlink a file being streamed on Windows. + atomic(path.join(root, job.project, indexFile('latest', kindOf(job))), { id: job.id }); + // Keep three successful APKs per kind. Publishing Store must not evict Development. const ready = readdirSync(path.join(root, job.project)).filter(id => /^[a-f0-9-]{36}$/.test(id)) - .map(id => readJson(path.join(jobDir(root, job.project, id), 'job.json'))).filter(j => j?.status === 'ready') as Job[]; + .map(id => readJson(path.join(jobDir(root, job.project, id), 'job.json'))).filter(j => j?.status === 'ready' && kindOf(j) === kindOf(job)) as Job[]; for (const old of ready.sort((a, b) => b.finishedAt! - a.finishedAt!).slice(3)) { try { unlinkSync(path.join(jobDir(root, old.project, old.id), 'app.apk')); } catch { /* Retry retention after the next successful build. */ } } @@ -39,7 +39,7 @@ export async function run(root: string, project: string, id: string) { }); child.once('error', reject); child.once('exit', resolve); }); - if (code !== 0) throw new Error('Dev-Build fehlgeschlagen. Details im Build-Protokoll.'); + if (code !== 0) throw new Error('APK-Build fehlgeschlagen. Details im Build-Protokoll.'); await publish(root, job); } catch (error) { job.status = 'failed'; job.finishedAt = Date.now(); job.error = (error as Error).message; saveJob(root, job); diff --git a/src/server/dev-apk/storage.ts b/src/server/dev-apk/storage.ts index c333e7d62..fc6b448a2 100644 --- a/src/server/dev-apk/storage.ts +++ b/src/server/dev-apk/storage.ts @@ -1,11 +1,14 @@ import { createHash, randomUUID } from 'node:crypto'; import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import type { DevApkBuild } from '../../shared/dev-apk.js'; +import type { ApkKind, DevApkBuild } from '../../shared/dev-apk.js'; import type { DevApkConfig } from './config.js'; import { BuildError } from './config.js'; export interface Job extends DevApkBuild { config: DevApkConfig; pid: number; project: string } export const rootFor = (dir: string) => path.join(dir, 'dev-apk'); +// Keep legacy development indexes and links valid; Store never falls back to them. +export const kindOf = (job: DevApkBuild): ApkKind => job.kind ?? 'development'; +export const indexFile = (name: 'latest' | 'recent', kind: ApkKind) => kind === 'store' ? `${name}-store.json` : `${name}.json`; export const projectFor = (repo: string) => createHash('sha256').update(repo.toLowerCase()).digest('hex').slice(0, 24); export const jobDir = (root: string, project: string, id: string) => { if (!/^[a-f0-9]{24}$/.test(project) || !/^[a-f0-9-]{36}$/.test(id)) throw new BuildError('Ungültiger Build.', 404); @@ -38,7 +41,7 @@ export function reserve(root: string, job: Job) { mkdirSync(root, { recursive: true }); activeJob(root); try { const fd = openSync(path.join(root, 'active.json'), 'wx'); try { writeFileSync(fd, JSON.stringify({ id: job.id, project: job.project, pid: job.pid })); } finally { closeSync(fd); } } - catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') throw new BuildError('Ein Dev-Build läuft bereits. Bitte dessen Abschluss abwarten.', 409); throw e; } + catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') throw new BuildError('Ein APK-Build läuft bereits. Bitte dessen Abschluss abwarten.', 409); throw e; } } export function release(root: string, id: string) { const file = path.join(root, 'active.json'); diff --git a/src/server/http/routes/dev-apk.ts b/src/server/http/routes/dev-apk.ts index fc2194d6d..da4f04f15 100644 --- a/src/server/http/routes/dev-apk.ts +++ b/src/server/http/routes/dev-apk.ts @@ -2,7 +2,7 @@ import type { Route } from '../router.js'; import { sameOrigin, send } from '../util.js'; import { floorParam } from './files.js'; import { start, view } from '../../dev-apk/manager.js'; -import { configFor, BuildError } from '../../dev-apk/config.js'; +import { configFor, parseKind, BuildError } from '../../dev-apk/config.js'; import { download } from '../../dev-apk/download.js'; import { projectFor, rootFor } from '../../dev-apk/storage.js'; export const devApkRoute: Route = { @@ -10,20 +10,21 @@ export const devApkRoute: Route = { async handle(ctx, { req, res, url, path, session }) { const floor = floorParam(ctx, url); if (!floor) return send(res, 404, { error: 'Projekt nicht gefunden.' }); try { + const kind = parseKind(url.searchParams.get('kind')); if (path.endsWith('/download')) { if (!['GET', 'HEAD'].includes(req.method ?? '')) return send(res, 405, { error: 'GET oder HEAD verwenden.' }); const config = configFor(ctx.cfg.dataDir, floor.def.repo); - if (!config) return send(res, 404, { error: 'Kein Dev-APK-Download eingerichtet.' }); - return await download(rootFor(ctx.cfg.dataDir), projectFor(config.repo), url.searchParams.get('id'), req, res); + if (!config) return send(res, 404, { error: 'Kein APK-Download eingerichtet.' }); + return await download(rootFor(ctx.cfg.dataDir), projectFor(config.repo), url.searchParams.get('id'), req, res, kind); } - if (req.method === 'GET') return send(res, 200, view(ctx.cfg.dataDir, floor.def.repo, session)); + if (req.method === 'GET') return send(res, 200, view(ctx.cfg.dataDir, floor.def.repo, session, kind)); if (req.method !== 'POST') return send(res, 405, { error: 'GET oder POST verwenden.' }); if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Build im Office starten.' }); // No command, path, ref, or launch arguments are accepted from a browser. - const job = start(ctx.cfg.dataDir, floor.def.repo, session); return send(res, 202, job); + const job = start(ctx.cfg.dataDir, floor.def.repo, session, undefined, kind); return send(res, 202, job); } catch (error) { if (res.headersSent) { res.destroy(); return; } - return send(res, error instanceof BuildError ? error.status : 500, { error: error instanceof BuildError ? error.message : 'Dev-APK konnte nicht verarbeitet werden. Serverprotokoll prüfen.' }); + return send(res, error instanceof BuildError ? error.status : 500, { error: error instanceof BuildError ? error.message : 'APK konnte nicht verarbeitet werden. Serverprotokoll prüfen.' }); } }, }; diff --git a/src/shared/dev-apk.ts b/src/shared/dev-apk.ts index 368f7b721..2f5c579ab 100644 --- a/src/shared/dev-apk.ts +++ b/src/shared/dev-apk.ts @@ -1,4 +1,6 @@ +export type ApkKind = 'development' | 'store'; export interface DevApkBuild { + kind?: ApkKind; id: string; status: 'starting' | 'waiting' | 'building' | 'publishing' | 'ready' | 'failed' | 'interrupted'; requestedBy: string; diff --git a/tests/dev-apk-browser.mjs b/tests/dev-apk-browser.mjs index ea4b35874..e025d3fc8 100644 --- a/tests/dev-apk-browser.mjs +++ b/tests/dev-apk-browser.mjs @@ -15,12 +15,15 @@ const dir = mkdtempSync(path.join(tmpdir(), 'apk-browser-')), source = path.join mkdirSync(path.join(source, 'Tools'), { recursive: true }); writeFileSync(path.join(source, 'Tools/Build-Quest.ps1'), "throw 'Fixture build failed'"); writeFileSync(path.join(source, 'Tools/BuildLock.ps1'), "throw 'Fixture build failed before Unity starts'"); -const config = { repo: 'owner/game', sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk' }; +const config = { repo: 'owner/game', sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk', storeApkName: 'Game.apk' }; atomic(path.join(dir, 'dev-apk.json'), { projects: [config] }); const root = rootFor(dir), project = projectFor(config.repo); const job = { id: randomUUID(), project, config, pid: process.pid, status: 'publishing', requestedBy: 'Kajo', startedAt: Date.now(), ref: 'origin/main' }; saveJob(root, job); const folder = jobDir(root, project, job.id); writeFileSync(path.join(folder, 'app.apk.part'), Buffer.from('PK\x03\x04fixture APK')); writeFileSync(path.join(folder, 'revision.txt'), 'b'.repeat(40)); await publish(root, job); +const storeJob = { ...job, id: randomUUID(), kind: 'store', status: 'publishing' }; +saveJob(root, storeJob); const storeFolder = jobDir(root, project, storeJob.id); +writeFileSync(path.join(storeFolder, 'app.apk.part'), Buffer.from('PK\x03\x04store APK')); writeFileSync(path.join(storeFolder, 'revision.txt'), 'c'.repeat(40)); await publish(root, storeJob); const session = { account: { id: 'admin', name: 'Kajo', role: 'admin' } }; const ctx = { cfg: { dataDir: dir, port: 0, trustProxy: false }, services: { lookup: () => undefined }, auth: { fromRequest: () => session }, floors: new Map([['game', { def: { repo: config.repo } }]]) }; const handler = requestHandler(ctx, [devApkRoute]); @@ -46,9 +49,21 @@ try { assert.equal(download.status(), 200); assert.equal((await download.body()).subarray(0, 4).toString('hex'), '504b0304'); const output = process.env.APK_SCREENSHOT_DIR || 'E:/source/agent-office-deployment/dev-apk-screenshots'; mkdirSync(output, { recursive: true }); await page.screenshot({ path: path.join(output, 'desktop.png'), fullPage: true }); + await page.getByRole('button', { name: 'Store-APK', exact: true }).click(); + await page.getByRole('heading', { name: 'Neueste erfolgreiche Store-APK', exact: true }).waitFor(); + const storeLink = await page.getByRole('link', { name: 'APK herunterladen', exact: true }).getAttribute('href'); + assert.ok(storeLink.includes('kind=store') && storeLink.includes(storeJob.id)); + const storeDownload = await page.request.get(new URL(storeLink, origin).href); + assert.equal((await storeDownload.body()).toString(), 'PK\x03\x04store APK'); + const storePost = page.waitForResponse(r => r.request().method() === 'POST' && r.url().includes('kind=store')); + await page.getByRole('button', { name: 'Store-APK bauen', exact: true }).click(); assert.equal((await storePost).status(), 202); + await page.getByText(/Build fehlgeschlagen/).waitFor({ timeout: 30000 }); + assert.equal(view(dir, config.repo, session, 'store').latest.id, storeJob.id); + assert.equal(view(dir, config.repo, session).latest.id, job.id); + await page.screenshot({ path: path.join(output, 'store-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(output, 'mobile.png'), fullPage: true }); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); await page.keyboard.press('Escape'); assert.equal(await page.getByRole('dialog').count(), 0); await page.evaluate(() => window.show()); await page.getByRole('button', { name: 'Close', exact: true }).click(); assert.equal(await page.getByRole('dialog').count(), 0); - assert.deepEqual(errors, []); console.log('PASS: actual build trigger/failure, previous download, desktop/mobile, Escape and close.'); + assert.deepEqual(errors, []); console.log('PASS: both build triggers/failures, distinct previous downloads, Store selection, desktop/mobile, Escape and close.'); } finally { await browser?.close(); await vite.close(); rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/dev-apk.test.ts b/tests/dev-apk.test.ts index 548837e77..45327b94a 100644 --- a/tests/dev-apk.test.ts +++ b/tests/dev-apk.test.ts @@ -8,7 +8,8 @@ import http from 'node:http'; import { configFor } from '../src/server/dev-apk/config.js'; import { start, view } from '../src/server/dev-apk/manager.js'; import { activeJob, atomic, jobDir, projectFor, release, rootFor, saveJob, type Job } from '../src/server/dev-apk/storage.js'; -import { publish } from '../src/server/dev-apk/runner.js'; +import { publish, run } from '../src/server/dev-apk/runner.js'; +import type { ApkKind } from '../src/shared/dev-apk.js'; import { byteRange } from '../src/server/dev-apk/download.js'; import { devApkRoute } from '../src/server/http/routes/dev-apk.js'; import { requestHandler } from '../src/server/http/router.js'; @@ -17,12 +18,12 @@ function fixture(t: any) { const dir = mkdtempSync(path.join(tmpdir(), 'office-apk-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const source = path.join(dir, 'source'); mkdirSync(path.join(source, 'Tools'), { recursive: true }); for (const file of ['Build-Quest.ps1', 'BuildLock.ps1']) writeFileSync(path.join(source, 'Tools', file), '# test fixture'); - const config = { repo, sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk', builders: ['builder'] }; + const config = { repo, sourceDir: source, buildDir: path.join(dir, 'build'), ref: 'origin/main', apkName: 'Game-dev.apk', storeApkName: 'Game.apk', builders: ['builder'] }; atomic(path.join(dir, 'dev-apk.json'), { projects: [config] }); return { dir, config, root: rootFor(dir), project: projectFor(repo) }; } -async function ready(f: ReturnType, bytes = Buffer.from('PK\x03\x04first apk'), finishedOffset = 0) { - const job: Job = { id: randomUUID(), project: f.project, config: f.config, pid: process.pid, requestedBy: 'Tester', ref: 'origin/main', startedAt: Date.now(), status: 'publishing' }; +async function ready(f: ReturnType, bytes = Buffer.from('PK\x03\x04first apk'), finishedOffset = 0, kind?: ApkKind) { + const job: Job = { id: randomUUID(), kind, project: f.project, config: f.config, pid: process.pid, requestedBy: 'Tester', ref: 'origin/main', startedAt: Date.now(), status: 'publishing' }; saveJob(f.root, job); const folder = jobDir(f.root, f.project, job.id); writeFileSync(path.join(folder, 'app.apk.part'), bytes); writeFileSync(path.join(folder, 'revision.txt'), 'a'.repeat(40)); await publish(f.root, job); job.finishedAt! += finishedOffset; saveJob(f.root, job); return job; @@ -36,6 +37,7 @@ test('builds use local config only, enforce permission and reject duplicate star const j = start(f.dir, repo, admin, (_, job) => { launched = job; })!; assert.equal(launched?.config.ref, 'origin/main'); assert.equal(view(f.dir, repo, admin).busy, true); assert.throws(() => start(f.dir, repo, admin, () => {}), /läuft bereits/); + assert.throws(() => start(f.dir, repo, admin, () => {}, 'store'), /läuft bereits/); assert.equal(readFileSync(path.join(f.config.sourceDir, 'Tools/Build-Quest.ps1'), 'utf8'), '# test fixture'); release(f.root, j.id); }); @@ -45,6 +47,8 @@ test('unsafe config cannot designate a parent of the source checkout as disposab assert.throws(() => configFor(f.dir, repo), /nicht enthalten/); atomic(path.join(f.dir, 'dev-apk.json'), { projects: [{ ...f.config, ref: 'origin/main; bad' }] }); assert.throws(() => configFor(f.dir, repo), /Konfiguration prüfen/); + atomic(path.join(f.dir, 'dev-apk.json'), { projects: [{ ...f.config, storeApkName: 'game-DEV.apk' }] }); + assert.throws(() => configFor(f.dir, repo), /Konfiguration prüfen/); }); test('previous download survives invalid or failed publication, exact artifact gets hash and commit', async t => { const f = fixture(t), good = await ready(f); @@ -83,6 +87,9 @@ test('HTTP download requires office login, streams ranges and pins versions whil t.after(() => { server.closeAllConnections(); server.close(); }); const origin = `http://127.0.0.1:${(server.address() as any).port}`, url = origin + '/api/dev-apk/download?floor=game', headers = { cookie: 'signed-in' }; assert.equal((await fetch(url)).status, 401); + assert.equal((await fetch(url + '&kind=store', { headers })).status, 404); + assert.equal((await fetch(url + '&kind=invalid', { headers })).status, 400); + assert.equal((await fetch(origin + '/api/dev-apk?floor=game&kind=invalid', { method: 'POST', headers: { ...headers, origin } })).status, 400); assert.equal((await fetch(origin + '/api/dev-apk?floor=game', { method: 'POST', headers })).status, 403); assert.equal((await fetch(origin + '/api/dev-apk?floor=game', { method: 'POST', headers: { ...headers, origin } })).status, 403); const response = await fetch(url, { headers }); assert.equal(response.status, 200); const etag = response.headers.get('etag')!; @@ -95,4 +102,64 @@ test('HTTP download requires office login, streams ranges and pins versions whil const pinned = await fetch(url + '&id=' + first.id, { headers }); assert.equal(await pinned.text(), 'PK\x03\x04first apk'); assert.equal((await fetch(url + '&id=../../escape', { headers })).status, 404); assert.equal((await fetch(origin + '/api/dev-apk?floor=missing', { headers })).status, 404); + const store = await ready(f, Buffer.from('PK\x03\x04store apk'), 0, 'store'); + const release = await fetch(url + '&kind=store', { headers }); + assert.equal(await release.text(), 'PK\x03\x04store apk'); + assert.match(release.headers.get('content-disposition')!, /store-/); + assert.equal((await fetch(url + '&id=' + store.id, { headers })).status, 404); + assert.equal((await fetch(url + '&kind=store&id=' + first.id, { headers })).status, 404); + assert.equal((await fetch(url + '&id=' + first.id, { headers })).status, 200); +}); + +test('Store and legacy Development have independent latest, failures, recent jobs and retention', async t => { + const f = fixture(t), legacy = await ready(f); + const stores: Job[] = []; + for (let i = 0; i < 4; i++) stores.push(await ready(f, undefined, i * 1000, 'store')); + assert.equal(view(f.dir, repo, admin).latest?.id, legacy.id); + assert.equal(view(f.dir, repo, admin, 'store').latest?.id, stores[3].id); + assert.equal(existsSync(path.join(jobDir(f.root, f.project, legacy.id), 'app.apk')), true); + assert.equal(existsSync(path.join(jobDir(f.root, f.project, stores[0].id), 'app.apk')), false); + await assert.rejects(ready(f, Buffer.from('invalid'), 0, 'store')); + assert.equal(view(f.dir, repo, admin, 'store').latest?.id, stores[3].id); + const current = start(f.dir, repo, admin, () => {}, 'store')!; + assert.equal(current.kind, 'store'); + assert.equal(view(f.dir, repo, admin, 'store').current?.id, current.id); + assert.equal(view(f.dir, repo, admin).current, undefined); + assert.equal(view(f.dir, repo, admin).busy, true); + release(f.root, current.id); +}); + +test('existing development-only config keeps downloads without enabling an unconfigured Store build', t => { + const f = fixture(t); + const { storeApkName: _, ...legacy } = f.config; + atomic(path.join(f.dir, 'dev-apk.json'), { projects: [legacy] }); + assert.equal(view(f.dir, repo, admin).canBuild, true); + assert.equal(view(f.dir, repo, admin, 'store').canBuild, false); + assert.throws(() => start(f.dir, repo, admin, () => {}, 'store'), /Dateiname/); +}); + +test('real PowerShell dispatch chooses only the requested switch and APK filename', { skip: process.platform !== 'win32' }, async t => { + const f = fixture(t); + // Fixture mutex and Git avoid sharing Unity resources or accessing the network. + writeFileSync(path.join(f.config.sourceDir, 'Tools/BuildLock.ps1'), ` +function Enter-BuildLock { $m = [Threading.Mutex]::new($false); [void]$m.WaitOne(); return $m } +function git { $global:LASTEXITCODE = 0; if ($args -contains 'rev-parse') { '${'b'.repeat(40)}' } } +`); + writeFileSync(path.join(f.config.sourceDir, 'Tools/Build-Quest.ps1'), ` +param([switch]$Development,[switch]$Store,[string]$Ref,[string]$Main,[string]$Folder) +if ($Development -eq $Store) { throw 'Exactly one build kind required' } +$out = Join-Path $Main 'Builds/Quest' +[void](New-Item -ItemType Directory -Force $out) +$file = if ($Store) { 'Game.apk' } else { 'Game-dev.apk' } +$content = if ($Store) { 'store' } else { 'development' } +[IO.File]::WriteAllBytes((Join-Path $out $file), [byte[]](80,75,3,4) + [Text.Encoding]::UTF8.GetBytes($content)) +$global:LASTEXITCODE = 0 +`); + for (const kind of ['development', 'store'] as const) { + const job = start(f.dir, repo, admin, () => {}, kind)!; + await run(f.root, f.project, job.id); + const result = view(f.dir, repo, admin, kind); + assert.equal(result.current?.status, 'ready', result.log); + assert.equal(readFileSync(path.join(jobDir(f.root, f.project, job.id), 'app.apk'), 'utf8'), 'PK\x03\x04' + kind); + } }); From 25cf96ee323dc1ef15a09d4447d1aff87053019a Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Sun, 11 Oct 2026 21:52:31 +0200 Subject: [PATCH 3/3] Add browser USB installation and launch for Quest APKs --- README.md | 2 + docs/code-layout.md | 2 + docs/dev-apk.md | 4 +- docs/quest-browser-install.md | 36 ++++++++++ package-lock.json | 88 ++++++++++++++++++++++- package.json | 5 ++ src/client/ui/dev-apk/extensions.ts | 3 + src/client/ui/dev-apk/index.ts | 7 +- src/client/ui/quest-install/controller.ts | 53 ++++++++++++++ src/client/ui/quest-install/device.ts | 69 ++++++++++++++++++ src/client/ui/quest-install/install.ts | 46 ++++++++++++ src/client/ui/quest-install/manifest.ts | 77 ++++++++++++++++++++ src/client/ui/quest-install/source.ts | 35 +++++++++ src/client/ui/quest-install/ui.css | 9 +++ src/client/ui/quest-install/ui.ts | 51 +++++++++++++ tests/quest-install-browser.mjs | 26 +++++++ tests/quest-install.test.ts | 55 ++++++++++++++ tests/support/quest-apk.ts | 9 +++ 18 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 docs/quest-browser-install.md create mode 100644 src/client/ui/dev-apk/extensions.ts create mode 100644 src/client/ui/quest-install/controller.ts create mode 100644 src/client/ui/quest-install/device.ts create mode 100644 src/client/ui/quest-install/install.ts create mode 100644 src/client/ui/quest-install/manifest.ts create mode 100644 src/client/ui/quest-install/source.ts create mode 100644 src/client/ui/quest-install/ui.css create mode 100644 src/client/ui/quest-install/ui.ts create mode 100644 tests/quest-install-browser.mjs create mode 100644 tests/quest-install.test.ts create mode 100644 tests/support/quest-apk.ts diff --git a/README.md b/README.md index 8eba45fb9..cb74f9d99 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ APKs für entfernte Tester: **☰ → APK** bietet getrennte Quest-Development- und signierte Store-Builds. Beide Varianten haben ihren eigenen neuesten erfolgreichen Download hinter dem Office-Login, auch über den bestehenden HTTPS-Tunnel. [Einrichtung und Bedienung](docs/dev-apk.md). +Direkt auf die Quest installieren: Im APK-Fenster **Quest verbinden** wählen. Chrome oder Edge verbindet sich per USB mit der Quest am Rechner des jeweiligen Testers. Die neueste Dev-/Store-APK oder eine ausgewählte Datei lässt sich mit Fortschrittsanzeige installieren und anschließend starten. [Browser-Installation](docs/quest-browser-install.md). + **A 3D office your team shares with its coding agents.** Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..079f43a1c 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -1,5 +1,7 @@ # Code layout +The APK dialog mounts its extensions through `src/client/ui/dev-apk/extensions.ts`. Browser-to-Quest installation lives in `src/client/ui/quest-install/`: source streaming, bounded APK manifest parsing, USB adapter, installation lifecycle and UI stay separate. It requires no server-side ADB endpoint and does not add device state to the shared office store. + Back to the [README](../README.md). Where the code lives, and where a new feature's pieces go. The office used to grow by adding to the middle of a few big files: the server's message switch, the page's key handler, its frame loop and its store. Each of those is a registry now, and a feature is a folder of its own plus one line in each list it joins. If a change has you editing the middle of `main.ts`, `server.ts`, the store or `protocol.ts`, it probably belongs somewhere else. diff --git a/docs/dev-apk.md b/docs/dev-apk.md index 5ed9f2d9d..c83ca4064 100644 --- a/docs/dev-apk.md +++ b/docs/dev-apk.md @@ -51,7 +51,9 @@ Der Proxy muss Cookies und Range-Header weiterleiten, die API darf nicht öffent ## Installation beim Tester -APK auf dem eigenen PC speichern, Quest-Entwicklermodus aktivieren, USB-Debugging freigeben und mit **Meta Quest Developer Hub** oder `adb install -r ` installieren. Unity ist nicht nötig. Der Quest-Browser installiert eine heruntergeladene APK nicht automatisch. +Im APK-Fenster **Quest verbinden** wählen, das USB-Gerät auswählen und USB-Debugging im Headset bestätigen. Anschließend die neueste Dev-/Store-APK oder eine zuvor heruntergeladene Datei installieren. **Spiel starten** wird nach bestätigter Installation freigeschaltet. Das funktioniert am Rechner des Testers auch über das entfernte Office. Voraussetzungen und Fehlerhilfe: [Auf Quest installieren](quest-browser-install.md). + +Alternativ bleibt die Installation mit **Meta Quest Developer Hub** oder `adb install -r ` möglich. Unity ist nicht nötig. Der Quest-Browser selbst installiert eine heruntergeladene APK nicht automatisch. Bei anderer Signatur als der Store-Version kann Android das Update ablehnen. Nicht automatisch deinstallieren: Lokale Spieldaten könnten verloren gehen. diff --git a/docs/quest-browser-install.md b/docs/quest-browser-install.md new file mode 100644 index 000000000..73dcb1ae9 --- /dev/null +++ b/docs/quest-browser-install.md @@ -0,0 +1,36 @@ +# APK aus dem Office auf die Quest installieren + +**☰ → APK → Auf Quest installieren** verbindet den Browser mit der Quest am eigenen Rechner. Auch wenn das Office bei einem Kollegen läuft, wird das lokal angeschlossene Headset verwendet. Unity, ein Office-Server oder ein Tunnel-Helfer sind beim Tester nicht erforderlich. + +## Ablauf + +1. Office in Chrome oder Edge auf dem Rechner öffnen, über HTTPS oder bei lokalem Betrieb über localhost. Im Office anmelden. +2. Quest-Entwicklermodus aktivieren, das eingeschaltete Headset mit einem USB-Datenkabel anschließen. Unter Windows kann der Meta-ADB-Treiber erforderlich sein. +3. **Quest verbinden** drücken, das Gerät im Browser auswählen und **USB-Debugging zulassen** im Headset bestätigen. Diese Freigaben sind notwendig; die Website kann sie nicht automatisch erteilen. +4. Oben **Dev-APK** oder **Store-APK** wählen und den passenden Installationsbutton drücken. Es wird genau der angezeigte erfolgreiche Build übertragen. Alternativ **APK-Datei auswählen** und danach **Ausgewählte APK installieren** drücken. +5. Während Übertragung, Prüfung und Installation USB verbunden lassen. Erst nach Androids Erfolgsbestätigung wird **Spiel starten** freigegeben. + +Für die neueste Office-APK ist kein vorheriger Download nötig. Der vorhandene HTTPS-Tunnel transportiert die APK zum Browser; USB führt vom Rechner des Testers zu dessen Quest. Der Office-Server erhält keine ADB-Verbindung zum entfernten Headset. Der ADB-Verbindungsschlüssel bleibt im Browserprofil auf diesem Rechner. + +## Fehler und Abbruch + +- Firefox und Safari bieten den benötigten WebUSB-Zugriff nicht. Bei fehlender Unterstützung bleibt der Download nutzbar und die Installation deaktiviert. +- Bei belegter USB-Verbindung SideQuest, Meta Quest Developer Hub und andere ADB-Verbindungen schließen. Ein im Hintergrund laufender ADB-Server muss gegebenenfalls ebenfalls beendet werden. Treiber und Datenkabel prüfen. +- Bei unterschiedlicher App-Signatur bleibt die vorhandene Installation erhalten. Eine passend signierte APK verwenden. Die Funktion deinstalliert keine App und löscht keine Spieldaten, um einen Signaturkonflikt zu umgehen. +- Versions-Downgrades werden nicht erzwungen. Speicherfehler, fehlende Anmeldung, unterbrochene Downloads und abgezogene Kabel erscheinen als Fehler, ohne Startfreigabe. +- **Abbrechen**, **✕** oder **Esc** bricht den laufenden Vorgang ab. ✕ und Esc schließen das Fenster unmittelbar und geben die Maussteuerung zurück. Falls Android bereits mit der Installation begonnen hat, kann es diese trotzdem abschließen: den App-Stand im Headset prüfen. +- **Trennen** gibt die USB-Verbindung frei. Beim Schließen wird nach dem Abbruch die Verbindung ebenfalls geschlossen. Erneutes Öffnen erfordert erneutes Verbinden. + +## Umfang und technische Grenzen + +Unterstützt werden einzelne APK-Dateien bis knapp 4 GiB mit binärem Android-Manifest, kein ZIP64 und keine APKS-/Split-Pakete. Paketname und Startaktivität werden aus APK und Gerät ermittelt; es ist kein Paketname fest einprogrammiert. Manifest und ZIP-Verzeichnis werden begrenzt gelesen, die APK selbst in kleinen Blöcken übertragen. + +Office-Dateien werden ausschließlich über die authentifizierte Download-Route derselben Office-Adresse mit fester Build-ID geladen. Nach der Übertragung wird deren SHA-256 auf dem Gerät geprüft. Bei lokal ausgewählten Dateien gibt es keine unabhängige Office-Prüfsumme; Android prüft die APK bei der Installation. Die temporäre Datei hat pro Versuch einen eigenen Namen und wird anschließend nach Möglichkeit entfernt. Es wird kein beliebiger ADB-Befehlseingang bereitgestellt. + +Ein Reverse Proxy darf WebUSB nicht durch `Permissions-Policy: usb=()` verbieten. Für die APK gelten dieselben Range-/Download-Voraussetzungen wie beim [APK-Download](dev-apk.md). + +## Prüfung + +`npm run typecheck`, `npm test`, `npm run build`. Gezielte Regressionen: `node --import tsx --test tests/quest-install.test.ts`. Browser-Abnahme: `node --import tsx tests/quest-install-browser.mjs`. + +Die Browser-Abnahme verwendet die echte Oberfläche, APK-Auswertung und Installationssteuerung mit simuliertem USB-Gerät. Sie prüft Auswahl, Benutzerinteraktion, Fortschritt, Startfreigabe, Fehler, Abbruch und Schließen. Ein echter Quest-Installationslauf und die USB-/Treiberkombination des entfernten Testers sind gesonderte Hardware-Prüfungen. diff --git a/package-lock.json b/package-lock.json index e3f643997..152ab1af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,11 @@ "@lydell/node-pty": "^1.2.0-beta.15", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "@yume-chan/adb": "2.6.4", + "@yume-chan/adb-credential-web": "2.1.0", + "@yume-chan/adb-daemon-webusb": "2.3.2", + "@yume-chan/stream-extra": "2.6.1", + "fflate": "0.8.3", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, @@ -2482,6 +2487,12 @@ "license": "MIT", "optional": true }, + "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/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -2889,6 +2900,82 @@ "addons/*" ] }, + "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/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/ansi-regex": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", @@ -3879,7 +3966,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fill-range": { diff --git a/package.json b/package.json index 66e7bd2e9..b2f21ff68 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,11 @@ "@lydell/node-pty": "^1.2.0-beta.15", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "@yume-chan/adb": "2.6.4", + "@yume-chan/adb-credential-web": "2.1.0", + "@yume-chan/adb-daemon-webusb": "2.3.2", + "@yume-chan/stream-extra": "2.6.1", + "fflate": "0.8.3", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, diff --git a/src/client/ui/dev-apk/extensions.ts b/src/client/ui/dev-apk/extensions.ts new file mode 100644 index 000000000..de4357afe --- /dev/null +++ b/src/client/ui/dev-apk/extensions.ts @@ -0,0 +1,3 @@ +import { questInstallPanel } from '../quest-install/ui'; +/** APK tools own their implementation and lifecycle; the build/download window only mounts them. */ +export const APK_EXTENSIONS = [questInstallPanel]; diff --git a/src/client/ui/dev-apk/index.ts b/src/client/ui/dev-apk/index.ts index c991742ee..242055f39 100644 --- a/src/client/ui/dev-apk/index.ts +++ b/src/client/ui/dev-apk/index.ts @@ -3,6 +3,7 @@ import { h, openModal, type Modal } from '../dom'; import { store } from '../../state'; import type { HudAction } from '../menu'; import type { ApkKind, DevApkBuild, DevApkView } from '../../../shared/dev-apk'; +import { APK_EXTENSIONS } from './extensions'; const labels: Record = { starting: 'Build wird gestartet', waiting: 'Warte auf freien Build-Platz', building: 'APK wird gebaut', publishing: 'Download wird vorbereitet', ready: 'Build erfolgreich', failed: 'Build fehlgeschlagen', interrupted: 'Build unterbrochen' }; let modal: Modal | undefined; export const devApkAction: HudAction = { id: 'dev-apk', icon: '📦', label: 'APK', section: 'Open', run: () => openDevApk() }; @@ -10,6 +11,7 @@ export function openDevApk() { if (modal) return; const floor = store.floor; if (!floor) return; let kind: ApkKind = 'development', request = 0; + const extensions = APK_EXTENSIONS.map(mount => mount()); const name = () => kind === 'store' ? 'Store-APK' : 'Dev-APK'; const url = () => '/api/dev-apk?floor=' + encodeURIComponent(floor) + '&kind=' + kind; const status = h('div.dev-apk-status', { role: 'status' }, 'Lade Build-Status…'); @@ -21,6 +23,7 @@ export function openDevApk() { if (pending || kind === value) return; kind = value; choices.forEach((button, i) => button.setAttribute('aria-pressed', String(i === (kind === 'store' ? 1 : 0)))); latest.replaceChildren(); current.replaceChildren(); log.textContent = ''; error.textContent = ''; status.textContent = 'Lade Build-Status…'; startButton.disabled = true; void refresh(); + extensions.forEach(extension => extension.update()); } }, value === 'store' ? 'Store-APK' : 'Dev-APK')); const startButton = h('button.btn.primary', { type: 'button', disabled: true, onclick: async () => { pending = true; startButton.disabled = true; choices.forEach(b => { b.disabled = true; }); error.textContent = ''; @@ -54,13 +57,13 @@ export function openDevApk() { h('details', {}, h('summary', {}, 'SHA-256 prüfen'), h('code', {}, apk.sha256 ?? ''))); } else latest.append(h('p', {}, 'Noch keine APK vorhanden. Nach dem ersten erfolgreichen Build erscheint hier der Download.')); log.textContent = data.log || 'Noch kein Build-Protokoll.'; + extensions.forEach(extension => extension.update(data.latest ? { name: name(), url: link(data.latest.id), bytes: data.latest.bytes!, sha256: data.latest.sha256! } : undefined)); } catch (e) { if (!stopped && selected === kind && sequence === request) { error.textContent = (e as Error).message; startButton.disabled = true; } } } modal = openModal(h('section.dev-apk-dialog', { role: 'dialog', 'aria-label': 'APK', 'aria-modal': 'true' }, h('header', {}, h('h2', {}, '📦 APK')), h('div.dev-apk-body', {}, h('p', {}, 'Dev- und Store-Versionen bauen und herunterladen. Unity wird nur auf dem Build-Rechner benötigt.'), h('div.dev-apk-actions', { role: 'group', 'aria-label': 'APK-Variante' }, ...choices), status, latest, current, error, logDetails, - h('details', {}, h('summary', {}, 'Auf der Quest installieren'), h('p', {}, 'APK auf dem eigenen PC herunterladen. Quest im Entwicklermodus per USB verbinden und die Datei mit Meta Quest Developer Hub oder ADB installieren. Unity ist dafür nicht nötig.'), - h('p', {}, 'Bei unterschiedlicher Signatur zur Store-Version ist kein direktes Update möglich. Eine vorhandene App nicht ohne Sicherung ihrer Daten deinstallieren.')))), { onClose: () => { stopped = true; clearInterval(timer); off(); modal = undefined; } }); + ...extensions.map(extension => extension.element))), { onClose: () => { stopped = true; clearInterval(timer); off(); extensions.forEach(extension => extension.dispose()); modal = undefined; } }); const timer = setInterval(() => { void refresh(); }, 2500); const off = store.on('floor', () => { if (store.floor !== floor) modal?.close(); }); void refresh(); diff --git a/src/client/ui/quest-install/controller.ts b/src/client/ui/quest-install/controller.ts new file mode 100644 index 000000000..2f36663ac --- /dev/null +++ b/src/client/ui/quest-install/controller.ts @@ -0,0 +1,53 @@ +import { requestQuest, type QuestDevice } from './device'; +import { explain, installApk, launchInstalled, type InstallProgress } from './install'; +import type { ApkSource } from './source'; +export interface QuestState extends InstallProgress { busy: boolean; connected?: string; installed?: string; error?: string; source?: string } +export class QuestInstaller { + state: QuestState = { busy: false, message: 'Quest per USB mit diesem Rechner verbinden.' }; + private device?: QuestDevice; + private abort?: AbortController; + private operation?: Promise; + private disposed = false; + constructor(private changed: (state: QuestState) => void, private connectDevice = requestQuest) {} + private update(value: Partial) { this.state = {...this.state,...value}; if (!this.disposed) this.changed(this.state); } + private run(task: (signal: AbortSignal) => Promise) { + if (this.state.busy || this.disposed) return Promise.resolve(); + this.abort = new AbortController(); const signal = this.abort.signal; + this.update({ busy: true, error: undefined, bytes: undefined, total: undefined }); + return this.operation = (async () => { + try { await task(signal); } + catch (e) { this.update({ error: signal.aborted ? 'Abgebrochen. Falls Android bereits installiert hat, bitte den App-Stand auf der Quest prüfen.' : explain(e), message: 'Vorgang beendet.' }); } + finally { this.abort = undefined; this.update({busy:false}); } + })(); + } + connect() { + return this.run(async signal => { + const old = this.device; this.device = undefined; this.update({connected:undefined,installed:undefined,source:undefined}); + if (old) void old.close().catch(() => {}); + const device = await this.connectDevice(signal, message => this.update({message})); + if (signal.aborted || this.disposed) { await device.close(); signal.throwIfAborted(); return; } + this.device = device; this.update({connected:device.name,message:'Quest verbunden. Wähle die APK zum Installieren.'}); + const disconnected = () => { if (this.device !== device) return; this.device = undefined; this.abort?.abort(); this.update({connected:undefined,installed:undefined,message:'Quest getrennt. Bitte erneut verbinden.'}); }; + void device.disconnected.then(disconnected, disconnected); + }); + } + install(source: ApkSource) { + return this.run(async signal => { + const device = this.device; if (!device) throw Error('Bitte zuerst die Quest verbinden.'); + this.update({ installed: undefined, source: source.name }); + const installed = await installApk(device, source, signal, value => this.update(value)); + if (this.device !== device) throw Error('Quest wurde getrennt.'); + this.update({installed,bytes:undefined,total:undefined}); + }); + } + launch() { + return this.run(async signal => { + if (!this.device || !this.state.installed) throw Error('Bitte zuerst eine APK erfolgreich installieren.'); + this.update({message:'Spiel wird gestartet…'}); + await launchInstalled(this.device,this.state.installed,signal); this.update({message:'Spiel gestartet. Setze dein Headset auf.'}); + }); + } + cancel() { this.abort?.abort(); } + async disconnect() { this.cancel(); await this.operation; const device = this.device; this.device = undefined; await device?.close().catch(()=>{}); this.update({connected:undefined,installed:undefined,message:'Quest getrennt.'}); } + async dispose() { this.disposed = true; await this.disconnect(); } +} diff --git a/src/client/ui/quest-install/device.ts b/src/client/ui/quest-install/device.ts new file mode 100644 index 000000000..69541de01 --- /dev/null +++ b/src/client/ui/quest-install/device.ts @@ -0,0 +1,69 @@ +import { Adb, AdbDaemonTransport } from '@yume-chan/adb'; +import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb'; +import AdbWebCredentialStore from '@yume-chan/adb-credential-web'; +import { ReadableStream as AdbReadableStream } from '@yume-chan/stream-extra'; +import type { ApkSource } from './source'; +export interface ShellResult { stdout: string; stderr: string; exitCode: number } +export interface QuestDevice { + name: string; disconnected: Promise; + push(path: string, source: ApkSource, signal: AbortSignal, progress: (bytes: number) => void): Promise; + shell(args: string[], signal: AbortSignal): Promise; + close(): Promise; +} +export function availability(): string | undefined { + if (!globalThis.isSecureContext) return 'Bitte das Office über HTTPS oder localhost öffnen.'; + if (!AdbDaemonWebUsbDeviceManager.BROWSER) return 'USB-Installation benötigt Chrome oder Edge auf deinem Rechner. Firefox und Safari unterstützen diese Verbindung nicht.'; +} +const quote = (s: string) => "'" + s.replaceAll("'", "'\\''") + "'"; +export async function requestQuest(signal: AbortSignal, status: (text: string) => void): Promise { + const unavailable = availability(); if (unavailable) throw Error(unavailable); + // Must run immediately in the connect button's user gesture, before any network request or lazy import. + const device = await AdbDaemonWebUsbDeviceManager.BROWSER!.requestDevice({ filters: [{ vendorId: 0x2833 }] }); + signal.throwIfAborted(); if (!device) throw Error('Keine Quest ausgewählt.'); + const cancel = () => { void device.raw.close().catch(() => {}); }; + signal.addEventListener('abort', cancel, { once: true }); + try { + status('Verbindung wird geöffnet…'); const connection = await device.connect(); signal.throwIfAborted(); + status('Bitte im Headset „USB-Debugging zulassen“ bestätigen.'); + const transport = await AdbDaemonTransport.authenticate({ serial: device.serial, connection, credentialStore: new AdbWebCredentialStore('Agent Office'), readTimeLimit: 120000 }); + const adb = new Adb(transport); + if (signal.aborted) { await adb.close(); signal.throwIfAborted(); } + return new UsbQuest(adb, device.name || adb.banner.model || 'Meta Quest'); + } catch (e) { cancel(); throw e; } + finally { signal.removeEventListener('abort', cancel); } +} +class UsbQuest implements QuestDevice { + readonly disconnected: Promise; + constructor(private adb: Adb, public name: string) { this.disconnected = adb.disconnected; } + close() { return this.adb.close(); } + async push(path: string, source: ApkSource, signal: AbortSignal, progress: (bytes: number) => void) { + signal.throwIfAborted(); const sync = await this.adb.sync(); + const cancel = () => { void sync.dispose().catch(() => {}); }; + signal.addEventListener('abort', cancel, { once: true }); + try { + signal.throwIfAborted(); let sent = 0; + const reader = (await source.stream(signal)).getReader(); + const abortRead = () => { void reader.cancel(signal.reason).catch(() => {}); }; + signal.addEventListener('abort', abortRead, { once: true }); + try { + const stream = new AdbReadableStream({ async pull(controller) { + signal.throwIfAborted(); const {done,value} = await reader.read(); signal.throwIfAborted(); + if (done) { if (sent !== source.size) throw Error('APK-Übertragung unvollständig.'); controller.close(); return; } + sent += value.length; if (sent > source.size) throw Error('Die APK ist größer als erwartet.'); progress(sent); controller.enqueue(value); + }, cancel: () => reader.cancel() }); + await sync.write({ filename: path, file: stream, permission: 0o600 }); signal.throwIfAborted(); + } finally { signal.removeEventListener('abort', abortRead); await reader.cancel().catch(() => {}); reader.releaseLock(); } + } finally { signal.removeEventListener('abort', cancel); await sync.dispose().catch(() => {}); } + } + async shell(args: string[], signal: AbortSignal): Promise { + const shell = this.adb.subprocess.shellProtocol; if (!shell) throw Error('Die Quest unterstützt die benötigte ADB-Verbindung nicht.'); + const proc = await shell.spawn(args.map(quote), signal); + const read = async (stream: typeof proc.stdout) => { + const reader = stream.getReader(), decoder = new TextDecoder(); let value = ''; + try { while (true) { const {done, value: chunk} = await reader.read(); if (done) break; value += decoder.decode(chunk, { stream: true }); if (value.length > 65536) throw Error('Unerwartet große Geräteantwort.'); } return value + decoder.decode(); } + finally { reader.releaseLock(); } + }; + try { const [stdout, stderr, exitCode] = await Promise.all([read(proc.stdout), read(proc.stderr), proc.exited]); signal.throwIfAborted(); return { stdout, stderr, exitCode }; } + catch (e) { await proc.kill(); throw e; } + } +} diff --git a/src/client/ui/quest-install/install.ts b/src/client/ui/quest-install/install.ts new file mode 100644 index 000000000..2a1124a00 --- /dev/null +++ b/src/client/ui/quest-install/install.ts @@ -0,0 +1,46 @@ +import { apkPackage, validPackage } from './manifest'; +import type { ApkSource } from './source'; +import type { QuestDevice } from './device'; +export interface InstallProgress { message: string; bytes?: number; total?: number } +export function explain(error: unknown): string { + const text = error instanceof Error ? error.message : String(error); + if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(text)) return 'Die vorhandene App hat eine andere Signatur. Es wurde nichts deinstalliert. Verwende eine passend signierte APK; eine Deinstallation würde App-Daten löschen.'; + if (/INSTALL_FAILED_VERSION_DOWNGRADE/i.test(text)) return 'Auf der Quest ist eine neuere Version installiert. Bitte einen neueren Build verwenden. Es wurde kein Downgrade erzwungen.'; + if (/INSTALL_FAILED_INSUFFICIENT_STORAGE|ENOSPC|No space left/i.test(text)) return 'Auf der Quest fehlt Speicherplatz. Bitte Platz freigeben und erneut versuchen.'; + if (/DeviceBusy|Unable to claim|already in use|claimInterface/i.test(text)) return 'Die USB-Verbindung wird bereits verwendet oder ist nicht verfügbar. SideQuest, Meta Quest Developer Hub und andere ADB-Verbindungen schließen. Unter Windows gegebenenfalls den Meta-ADB-Treiber prüfen.'; + if (/transfer|disconnected|device was lost|NetworkError/i.test(text)) return 'USB-Verbindung unterbrochen. Kabel und Headset prüfen und erneut verbinden. Falls Android schon installiert hat, den App-Stand auf der Quest prüfen.'; + return text.slice(0, 2000); +} +export async function installApk(device: QuestDevice, source: ApkSource, signal: AbortSignal, progress: (value: InstallProgress) => void): Promise { + signal.throwIfAborted(); progress({message: 'APK wird geprüft…'}); + const packageName = await apkPackage(source, signal), path = '/data/local/tmp/office-apk-' + crypto.randomUUID() + '.apk'; + signal.throwIfAborted(); + try { + await device.push(path, source, signal, bytes => progress({message: 'APK wird auf die Quest übertragen…', bytes, total: source.size})); + signal.throwIfAborted(); + if (source.sha256) { + progress({message: 'Übertragene APK wird verifiziert…'}); + const result = await device.shell(['sha256sum', path], signal); + if (result.exitCode || result.stdout.trim().split(/\s+/)[0]?.toLowerCase() !== source.sha256) throw Error('Die Prüfsumme stimmt nicht. Die APK wurde nicht installiert. Bitte erneut versuchen.'); + } + progress({message: 'Android installiert die APK. Bitte USB angeschlossen lassen…'}); + const result = await device.shell(['pm', 'install', '-r', '-t', path], signal); + if (result.exitCode !== 0 || !/^Success\s*$/m.test(result.stdout) || /Failure\s*\[/.test(result.stdout + result.stderr)) throw Error((result.stdout + '\n' + result.stderr).trim() || 'Android hat die Installation nicht bestätigt.'); + signal.throwIfAborted(); progress({message: 'APK erfolgreich installiert.'}); return packageName; + } finally { + // Only the unique temporary APK from this attempt; never uninstall or remove app data. + await device.shell(['rm', '-f', path], AbortSignal.timeout(5000)).catch(() => {}); + } +} +export async function launchInstalled(device: QuestDevice, packageName: string, signal: AbortSignal) { + if (!validPackage(packageName)) throw Error('Keine erfolgreich installierte App ausgewählt.'); + let component: string | undefined; + for (const category of ['android.intent.category.LAUNCHER', 'android.intent.category.INFO']) { + const result = await device.shell(['cmd', 'package', 'resolve-activity', '--brief', '-a', 'android.intent.action.MAIN', '-c', category, packageName], signal); + component = result.stdout.trim().split(/\r?\n/).find(line => line.startsWith(packageName + '/') && /^[A-Za-z0-9_.$/]+$/.test(line)); + if (!result.exitCode && component) break; component = undefined; + } + if (!component) throw Error('Kein startbarer Eintrag gefunden. Öffne die App über die Bibliothek im Headset.'); + const result = await device.shell(['am', 'start', '-a', 'android.intent.action.MAIN', '-n', component], signal); + if (result.exitCode || /Error|Exception/i.test(result.stdout + result.stderr)) throw Error((result.stdout + '\n' + result.stderr).trim() || 'App konnte nicht gestartet werden.'); +} diff --git a/src/client/ui/quest-install/manifest.ts b/src/client/ui/quest-install/manifest.ts new file mode 100644 index 000000000..8f164af75 --- /dev/null +++ b/src/client/ui/quest-install/manifest.ts @@ -0,0 +1,77 @@ +import { inflateSync } from 'fflate'; +import type { ApkSource } from './source'; +const MAX_MANIFEST = 8 * 1024 * 1024; +const bad = () => Error('Die APK enthält kein gültiges Android-Manifest. Bitte eine vollständige APK auswählen.'); +export const validPackage = (value: string) => /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/.test(value) && value.length < 256; +/** Read only the ZIP directory and bounded manifest, even for multi-GB game APKs. */ +export async function apkPackage(source: ApkSource, signal: AbortSignal): Promise { + if (source.size < 22 || source.size > 0xffffffff) throw bad(); + const tail = await source.read(Math.max(0, source.size - 65557), Math.min(source.size, 65557), signal); + const t = new DataView(tail.buffer, tail.byteOffset, tail.byteLength); let end = -1; + for (let p = tail.length - 22; p >= 0; p--) if (t.getUint32(p, true) === 0x06054b50 && p + 22 + t.getUint16(p + 20, true) === tail.length) { end = p; break; } + if (end < 0 || t.getUint16(end + 4, true) || t.getUint16(end + 6, true)) throw bad(); + const count = t.getUint16(end + 10, true), size = t.getUint32(end + 12, true), offset = t.getUint32(end + 16, true); + if (count === 65535 || size > 32 * 1024 * 1024 || offset + size > source.size - 22 || !size) throw bad(); + const directory = await source.read(offset, size, signal), d = new DataView(directory.buffer, directory.byteOffset, directory.byteLength); + let entry: { offset: number; compressed: number; size: number; method: number } | undefined; + for (let p = 0, n = 0; n < count; n++) { + if (p + 46 > size || d.getUint32(p, true) !== 0x02014b50) throw bad(); + const nameSize = d.getUint16(p + 28, true), next = p + 46 + nameSize + d.getUint16(p + 30, true) + d.getUint16(p + 32, true); + if (next > size) throw bad(); + if (new TextDecoder().decode(directory.subarray(p + 46, p + 46 + nameSize)) === 'AndroidManifest.xml') { + if (entry || (d.getUint16(p + 8, true) & 1)) throw bad(); + entry = { offset: d.getUint32(p + 42, true), compressed: d.getUint32(p + 20, true), size: d.getUint32(p + 24, true), method: d.getUint16(p + 10, true) }; + } + p = next; + } + if (!entry || ![0,8].includes(entry.method) || entry.size > MAX_MANIFEST || entry.compressed > MAX_MANIFEST || entry.size < 8 || entry.offset + 30 > offset) throw bad(); + const header = await source.read(entry.offset, 30, signal), h = new DataView(header.buffer, header.byteOffset, header.byteLength); + if (h.getUint32(0, true) !== 0x04034b50 || h.getUint16(8, true) !== entry.method || (h.getUint16(6, true) & 1)) throw bad(); + const start = entry.offset + 30 + h.getUint16(26, true) + h.getUint16(28, true); + if (start + entry.compressed > offset || !entry.compressed) throw bad(); + const zipped = await source.read(start, entry.compressed, signal); + const xml = entry.method === 0 ? zipped : inflateSync(zipped, { out: new Uint8Array(entry.size) }); + if (xml.length !== entry.size) throw bad(); + return manifestPackage(xml); +} +export function manifestPackage(xml: Uint8Array): string { + try { + const d = new DataView(xml.buffer, xml.byteOffset, xml.byteLength); + if (d.getUint16(0, true) !== 3 || d.getUint16(2, true) !== 8 || d.getUint32(4, true) !== xml.length) throw bad(); + let strings: string[] = []; + for (let p = 8; p < xml.length;) { + const type = d.getUint16(p, true), header = d.getUint16(p + 2, true), size = d.getUint32(p + 4, true), end = p + size; + if (header < 8 || size < header || end > xml.length) throw bad(); + if (type === 1) { + if (header < 28) throw bad(); + const count = d.getUint32(p + 8, true), utf8 = !!(d.getUint32(p + 16, true) & 0x100), start = p + d.getUint32(p + 20, true); + if (count > 100000 || p + header + count * 4 > start || start > end) throw bad(); + strings = []; + for (let i = 0; i < count; i++) { + let at = start + d.getUint32(p + header + i * 4, true); + const length = () => { + if (at + (utf8 ? 1 : 2) > end) throw bad(); + let n = utf8 ? d.getUint8(at++) : d.getUint16(at, true); if (!utf8) at += 2; + const flag = utf8 ? 0x80 : 0x8000; + if (n & flag) { if (at + (utf8 ? 1 : 2) > end) throw bad(); n = (n & (flag - 1)) * (utf8 ? 256 : 65536) + (utf8 ? d.getUint8(at++) : d.getUint16(at, true)); if (!utf8) at += 2; } return n; + }; + let n = length(); if (utf8) n = length(); else n *= 2; + if (at < start || at + n + (utf8 ? 1 : 2) > end) throw bad(); + strings.push(new TextDecoder(utf8 ? 'utf-8' : 'utf-16le', { fatal: true }).decode(xml.subarray(at, at + n))); + } + } else if (type === 0x0102) { + if (header !== 16 || size < 36) throw bad(); + const at = p + 16, name = strings[d.getUint32(at + 4, true)], first = at + d.getUint16(at + 8, true), step = d.getUint16(at + 10, true), count = d.getUint16(at + 12, true); + if (step < 20 || first < at + 20 || first + count * step > end) throw bad(); + if (name === 'manifest') for (let i = 0; i < count; i++) { + const a = first + i * step; + if (d.getUint32(a, true) !== 0xffffffff || strings[d.getUint32(a + 4, true)] !== 'package') continue; + const raw = d.getUint32(a + 8, true), value = strings[raw === 0xffffffff && d.getUint8(a + 15) === 3 ? d.getUint32(a + 16, true) : raw]; + if (!value || !validPackage(value)) throw bad(); return value; + } + } + p = end; + } + } catch { throw bad(); } + throw bad(); +} diff --git a/src/client/ui/quest-install/source.ts b/src/client/ui/quest-install/source.ts new file mode 100644 index 000000000..ece387493 --- /dev/null +++ b/src/client/ui/quest-install/source.ts @@ -0,0 +1,35 @@ +export interface OfficeApk { name: string; url: string; bytes: number; sha256: string } +export interface ApkSource { + name: string; size: number; sha256?: string; + read(offset: number, length: number, signal: AbortSignal): Promise; + stream(signal: AbortSignal): Promise>; +} +export function localApk(file: File): ApkSource { + if (!file.name.toLowerCase().endsWith('.apk')) throw Error('Bitte eine APK-Datei auswählen.'); + return { name: file.name, size: file.size, + async read(offset, length, signal) { signal.throwIfAborted(); const data = new Uint8Array(await file.slice(offset, offset + length).arrayBuffer()); signal.throwIfAborted(); return data; }, + async stream(signal) { signal.throwIfAborted(); return file.stream(); } }; +} +export function officeApk(apk: OfficeApk, origin = location.origin): ApkSource { + const url = new URL(apk.url, origin); + if (url.origin !== origin || url.pathname !== '/api/dev-apk/download' || !url.searchParams.get('id')) throw Error('Ungültiger Office-Build. Bitte aktualisieren.'); + if (!Number.isSafeInteger(apk.bytes) || apk.bytes < 22 || !/^[a-f0-9]{64}$/i.test(apk.sha256)) throw Error('Build-Metadaten fehlen. Bitte aktualisieren.'); + const request = async (signal: AbortSignal, range?: string) => { + const r = await fetch(url, { credentials: 'same-origin', redirect: 'error', cache: 'no-store', signal, headers: range ? { Range: range } : {} }); + if (r.status === 401 || r.status === 403) throw Error('Bitte erneut im Office anmelden.'); + if (r.status !== (range ? 206 : 200)) { await r.body?.cancel(); throw Error('APK nicht verfügbar oder Download unterbrochen. Bitte aktualisieren und erneut versuchen.'); } + return r; + }; + return { name: apk.name, size: apk.bytes, sha256: apk.sha256.toLowerCase(), + async read(offset, length, signal) { + if (offset < 0 || length < 1 || offset + length > apk.bytes) throw Error('Ungültiger APK-Dateibereich.'); + const r = await request(signal, `bytes=${offset}-${offset + length - 1}`); + if (r.headers.get('content-range') !== `bytes ${offset}-${offset + length - 1}/${apk.bytes}`) { await r.body?.cancel(); throw Error('APK-Dateibereich wurde vom Server nicht korrekt geliefert.'); } + const data = new Uint8Array(await r.arrayBuffer()); if (data.length !== length) throw Error('APK-Download unvollständig.'); return data; + }, + async stream(signal) { + const r = await request(signal); + if (!r.body || Number(r.headers.get('content-length')) !== apk.bytes) { await r.body?.cancel(); throw Error('Die APK-Größe stimmt nicht mit dem ausgewählten Build überein.'); } + return r.body; + } }; +} diff --git a/src/client/ui/quest-install/ui.css b/src/client/ui/quest-install/ui.css new file mode 100644 index 000000000..46713e6bd --- /dev/null +++ b/src/client/ui/quest-install/ui.css @@ -0,0 +1,9 @@ +.quest-installer { border:2px solid #303048; border-radius:12px; padding:16px; background:#edf4ff; } +.quest-installer h3 { margin:0 0 12px; } +.quest-installer p,.quest-installer li { overflow-wrap:anywhere; } +.quest-installer details { margin-bottom:16px; } +.quest-actions { display:flex; flex-wrap:wrap; gap:10px; margin:12px 0; } +.quest-progress { display:flex; align-items:center; gap:12px; } +.quest-progress progress { flex:1; min-width:80px; } +.quest-error { color:#9a2538; } +.quest-installer [hidden] { display:none !important; } diff --git a/src/client/ui/quest-install/ui.ts b/src/client/ui/quest-install/ui.ts new file mode 100644 index 000000000..6ae3f214e --- /dev/null +++ b/src/client/ui/quest-install/ui.ts @@ -0,0 +1,51 @@ +import './ui.css'; +import { h } from '../dom'; +import { availability } from './device'; +import { QuestInstaller } from './controller'; +import { localApk, officeApk, type ApkSource, type OfficeApk } from './source'; +export interface ApkExtension { element: HTMLElement; update(apk?: OfficeApk): void; dispose(): void } +export function questInstallPanel(): ApkExtension { + let selected: ApkSource | undefined, latest: OfficeApk | undefined; + const unsupported = availability(), status = h('p', {role:'status','aria-live':'polite'}), error = h('p.quest-error', {role:'alert'}); + const device = h('strong'), source = h('p'), chosen = h('p', {}, 'Keine lokale APK ausgewählt.'); + const progress = h('progress', { max: 100, value: 0, 'aria-label':'APK übertragen' }), amount = h('span'); + const controller = new QuestInstaller(render); + const connect = h('button.btn', {type:'button',onclick:()=>void controller.connect()}, 'Quest verbinden'); + const disconnect = h('button.btn', {type:'button',onclick:()=>void controller.disconnect()}, 'Trennen'); + const installLatest = h('button.btn.primary', {type:'button',onclick:()=>{ + if (!latest) return; try { void controller.install(officeApk(latest)); } catch(e) { error.textContent=(e as Error).message; } + }}, 'Neueste APK installieren'); + const file = h('input', {type:'file',accept:'.apk,application/vnd.android.package-archive',hidden:true,'aria-label':'APK-Datei',onchange:()=>{ + if (controller.state.busy || !file.files?.[0]) return; + try { selected = localApk(file.files[0]); chosen.textContent = selected.name; } + catch(e) { selected = undefined; render(); error.textContent=(e as Error).message; return; } render(); file.value=''; + }}); + const pick = h('button.btn', {type:'button',onclick:()=>file.click()}, 'APK-Datei auswählen'); + const installFile = h('button.btn', {type:'button',onclick:()=>{if(selected)void controller.install(selected);}}, 'Ausgewählte APK installieren'); + const launch = h('button.btn.primary', {type:'button',onclick:()=>void controller.launch()}, 'Spiel starten'); + const cancel = h('button.btn', {type:'button',onclick:()=>controller.cancel()}, 'Abbrechen'); + function render() { + const s = controller.state; + device.textContent = s.connected ? 'Verbunden: '+s.connected : 'Keine Quest verbunden'; status.textContent = s.message; error.textContent = s.error ?? ''; + source.textContent = s.source ? 'APK: '+s.source : ''; + connect.disabled = !!unsupported || s.busy || !!s.connected; disconnect.disabled = !s.connected || s.busy; + installLatest.disabled = !!unsupported || !s.connected || !latest || s.busy; + installLatest.textContent = latest ? latest.name+' installieren' : 'Neueste APK installieren'; + pick.disabled = s.busy; installFile.disabled = !!unsupported || !s.connected || !selected || s.busy; + launch.disabled = !s.connected || !s.installed || s.busy; cancel.hidden = !s.busy; + progress.hidden = !s.busy || !s.total; amount.hidden = progress.hidden; + if(s.total){progress.value = Math.min(100,100*(s.bytes??0)/s.total);amount.textContent=`${Math.round((s.bytes??0)/1048576)} / ${Math.round(s.total/1048576)} MB`;} + } + const element = h('section.quest-installer', {'aria-label':'Auf Quest installieren'},h('h3',{},'Auf Quest installieren'), + h('p',{},'Die Quest muss an diesem Rechner angeschlossen sein – auch wenn das Office bei jemand anderem läuft.'), + h('details',{},h('summary',{},'Einmalige Einrichtung & Verbindungshilfe'),h('ol',{}, + h('li',{},'Office in Chrome oder Edge über HTTPS öffnen.'),h('li',{},'Entwicklermodus für die Quest aktivieren und ein USB-Datenkabel anschließen.'), + h('li',{},'„Quest verbinden“ drücken, das Gerät auswählen und im Headset USB-Debugging zulassen.'), + h('li',{},'Wenn die Verbindung belegt ist: SideQuest, Meta Quest Developer Hub und andere ADB-Programme schließen. Unter Windows gegebenenfalls den Meta-ADB-Treiber installieren.')), + h('p',{},'Der Verbindungsschlüssel bleibt in diesem Browser. Bei einem Signaturkonflikt bleibt die vorhandene App erhalten. Es werden keine App-Daten gelöscht und keine Downgrades erzwungen.')), + unsupported ? h('p.quest-error',{},unsupported) : null,device, + h('div.quest-actions',{},connect,disconnect), h('div.quest-actions',{},installLatest), + h('div.quest-actions',{},pick,file,installFile),chosen,source,status,error,h('div.quest-progress',{},progress,amount),h('div.quest-actions',{},launch,cancel)); + render(); + return { element,update(apk){latest=apk;render();},dispose(){void controller.dispose();} }; +} diff --git a/tests/quest-install-browser.mjs b/tests/quest-install-browser.mjs new file mode 100644 index 000000000..68dd6fbfb --- /dev/null +++ b/tests/quest-install-browser.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import {mkdirSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {createServer} from 'vite'; +import {chromium} from 'playwright-core'; +import {apkFixture} from './support/quest-apk.ts'; +const apks={development:apkFixture(),store:apkFixture('com.example.store')}; +const mock=`export function availability(){return window.questUnsupported?'Dieser Browser unterstützt WebUSB nicht. Bitte Chrome oder Edge verwenden.':undefined;} +export async function requestQuest(signal,status){window.questCalls.push('connect');if(!navigator.userActivation.isActive)throw Error('Missing connect gesture');status('Bitte USB-Debugging bestätigen');let gone;let packageName='com.example.game';let hash;return {name:'Meta Quest 3',disconnected:new Promise(r=>gone=r),async close(){window.questCalls.push('close');gone();},async push(path,source,signal,progress){window.questCalls.push('push');const reader=(await source.stream(signal)).getReader();const parts=[];let n=0;while(true){const {done,value}=await reader.read();if(done)break;n+=value.length;parts.push(value);progress(n);}const joined=new Uint8Array(n);let at=0;for(const part of parts){joined.set(part,at);at+=part.length;}hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',joined)),v=>v.toString(16).padStart(2,'0')).join('');if(window.questMode==='slow')await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));if(window.questMode==='disconnect'){gone();throw Error('disconnected');}},async shell(args,signal){window.questCalls.push(args);if(args[0]==='sha256sum')return {stdout:hash+' temp.apk',stderr:'',exitCode:0};if(args[0]==='pm')return {stdout:window.questMode==='signature'?'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]':'Success',stderr:'',exitCode:0};if(args[0]==='cmd')return {stdout:args.at(-1)+'/.MainActivity',stderr:'',exitCode:0};return {stdout:'',stderr:'',exitCode:0};}};}`; +const requests=[]; +const vite=await createServer({configFile:false,root:process.cwd(),server:{host:'127.0.0.1',port:0},plugins:[{name:'quest-test-transport',enforce:'pre',resolveId(source,importer){if(importer?.replaceAll('\\','/').includes('/ui/quest-install/')&&source==='./device')return '\0quest-device-fixture';},load(id){if(id==='\0quest-device-fixture')return mock;}},{name:'quest-test-page',configureServer(server){server.middlewares.use((req,res,next)=>{ +const url=new URL(req.url,'http://x');if(url.pathname==='/api/dev-apk'){const kind=url.searchParams.get('kind')||'development',bytes=apks[kind];res.setHeader('content-type','application/json');res.end(JSON.stringify({configured:true,canBuild:false,ref:'origin/main',latest:{id:'fixed-'+kind,kind,status:'ready',finishedAt:Date.now(),bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),revision:'123456789abcdef'}}));return;} +if(url.pathname==='/api/dev-apk/download'){requests.push(url.search);assert.equal(req.method,'GET');const kind=url.searchParams.get('kind'),bytes=apks[kind];assert.equal(url.searchParams.get('id'),'fixed-'+kind);const range=req.headers.range?.match(/^bytes=(\d+)-(\d+)$/);if(range){res.writeHead(206,{'content-range':`bytes ${range[1]}-${range[2]}/${bytes.length}`,'content-length':+range[2]-+range[1]+1});res.end(bytes.slice(+range[1],+range[2]+1));}else{res.writeHead(200,{'content-length':bytes.length});res.end(bytes);}return;} +if(url.pathname!=='/__quest-test')return next();res.setHeader('content-type','text/html');res.end(``); +});}}]}); +const out=process.env.QUEST_SCREENSHOTS || 'dist/quest-install-screenshots';mkdirSync(out,{recursive:true});let browser; +try{await vite.listen();const origin=`http://127.0.0.1:${vite.httpServer.address().port}`;browser=await chromium.launch({channel:'chrome',headless:true});const page=await browser.newPage({viewport:{width:1280,height:1100}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(origin+'/__quest-test');const panel=page.getByRole('region',{name:'Auf Quest installieren'});await panel.waitFor();await panel.scrollIntoViewIfNeeded();assert.equal(await page.getByRole('button',{name:'Dev-APK installieren',exact:true}).isDisabled(),true); +await page.getByRole('button',{name:'Quest verbinden',exact:true}).click();await page.getByText('Verbunden: Meta Quest 3',{exact:true}).waitFor();await page.getByRole('button',{name:'Dev-APK installieren',exact:true}).click();await page.getByText('APK erfolgreich installiert.',{exact:true}).waitFor();assert.ok(requests.every(s=>s.includes('kind=development')&&s.includes('id=fixed-development')));await page.getByRole('button',{name:'Spiel starten',exact:true}).click();await page.getByText('Spiel gestartet. Setze dein Headset auf.').waitFor(); +await page.getByRole('button',{name:'Store-APK',exact:true}).click();await page.getByRole('button',{name:'Store-APK installieren',exact:true}).waitFor();await page.evaluate(()=>window.questMode='signature');await page.getByRole('button',{name:'Store-APK installieren',exact:true}).click();await page.getByText(/Es wurde nichts deinstalliert/).waitFor();assert.equal(await page.getByRole('button',{name:'Spiel starten',exact:true}).isDisabled(),true);await panel.scrollIntoViewIfNeeded();await page.screenshot({path:out+'/signature-error.png'}); +await page.evaluate(()=>window.questMode='success');await page.getByLabel('APK-Datei',{exact:true}).setInputFiles({name:'lokaler-build.apk',mimeType:'application/vnd.android.package-archive',buffer:Buffer.from(apks.development)});await page.getByRole('button',{name:'Ausgewählte APK installieren',exact:true}).click();await page.getByText('APK erfolgreich installiert.',{exact:true}).waitFor();await panel.scrollIntoViewIfNeeded();await page.screenshot({path:out+'/desktop.png'}); +await page.setViewportSize({width:390,height:844});await panel.scrollIntoViewIfNeeded();await page.waitForTimeout(300);await page.screenshot({path:out+'/mobile.png'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); +await page.evaluate(()=>window.questMode='slow');await page.getByRole('button',{name:'Ausgewählte APK installieren',exact:true}).click();await page.getByText('APK wird auf die Quest übertragen…',{exact:true}).waitFor();await page.getByRole('button',{name:'Abbrechen',exact:true}).click();await page.getByText(/Abgebrochen\./).waitFor(); +await page.getByRole('button',{name:'Ausgewählte APK installieren',exact:true}).click();await page.getByText('APK wird auf die Quest übertragen…',{exact:true}).waitFor();await page.getByRole('button',{name:'Close',exact:true}).click();assert.equal(await page.getByRole('dialog').count(),0);await page.waitForFunction(()=>window.questCalls.includes('close')); +await page.evaluate(()=>window.show());await page.keyboard.press('Escape');assert.equal(await page.getByRole('dialog').count(),0); +await page.evaluate(()=>{window.questUnsupported=true;window.show();});assert.equal(await page.getByRole('button',{name:'Quest verbinden',exact:true}).isDisabled(),true);await page.getByText(/Dieser Browser unterstützt WebUSB nicht/).waitFor();assert.deepEqual(errors,[]);const calls=await page.evaluate(()=>window.questCalls);assert.ok(!calls.some(c=>Array.isArray(c)&&(c.includes('uninstall')||c.includes('-d'))));console.log('PASS: browser USB transport fixture, Dev/Store immutable downloads, local APK, progress/cancel, Android failure, launch, unsupported browser, X/Esc/dispose, desktop/mobile.'); +}finally{await browser?.close();await vite.close();} diff --git a/tests/quest-install.test.ts b/tests/quest-install.test.ts new file mode 100644 index 000000000..f576a1a4f --- /dev/null +++ b/tests/quest-install.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { zipSync } from 'fflate'; +import { apkFixture, manifestFixture } from './support/quest-apk.js'; +import { apkPackage, manifestPackage } from '../src/client/ui/quest-install/manifest.js'; +import { localApk, officeApk, type ApkSource } from '../src/client/ui/quest-install/source.js'; +import { installApk, launchInstalled, explain } from '../src/client/ui/quest-install/install.js'; +import { QuestInstaller } from '../src/client/ui/quest-install/controller.js'; +import type { QuestDevice } from '../src/client/ui/quest-install/device.js'; +const signal=()=>new AbortController().signal; +const local=(data=apkFixture())=>localApk(new File([data as Uint8Array],'game.apk')); +function fakeDevice(output='Success\n') { + const commands:string[][]=[];let pushes=0,closed=0,resolve!:()=>void; + const device:QuestDevice={name:'Quest 3',disconnected:new Promise(r=>resolve=r),async close(){closed++;resolve();},async push(_p,source,sig,progress){pushes++;const reader=(await source.stream(sig)).getReader();let n=0;while(true){sig.throwIfAborted();const {done,value}=await reader.read();if(done)break;n+=value.length;progress(n);}assert.equal(n,source.size);},async shell(args){commands.push(args);return {stdout:args[0]==='pm'?output:args[0]==='cmd'?'com.example.game/.MainActivity\n':'',stderr:'',exitCode:0};}}; + return {device,commands,disconnect:()=>resolve(),get pushes(){return pushes;},get closed(){return closed;}}; +} +test('reads binary UTF8/UTF16 manifests from stored and deflated APKs without reading game payload',async()=>{ + for(const utf8 of [true,false])for(const level of [0,6] as const){const source=local(apkFixture('com.example.game',utf8,level)),reads:number[]=[];const read=source.read;source.read=async(o,n,s)=>{reads.push(n);return read(o,n,s);};assert.equal(await apkPackage(source,signal()),'com.example.game');assert.ok(reads.every(n=>n<=65557));} +}); +test('rejects corrupt manifests, non-APKs, shell-like package names and unsupported ZIP64 sizes',async()=>{ + assert.throws(()=>localApk(new File(['abc'],'file.txt')),/APK/); + assert.throws(()=>manifestPackage(manifestFixture('com.game;reboot')),/Manifest/); + const broken=manifestFixture();new DataView(broken.buffer).setUint32(12,0,true);assert.throws(()=>manifestPackage(broken),/Manifest/); + await assert.rejects(apkPackage(local(zipSync({'not-manifest':new Uint8Array(3)})),signal()),/Manifest/); + const source=local();source.size=0x100000000;await assert.rejects(apkPackage(source,signal()),/Manifest/); +}); +test('remote APK reads use immutable build id, authenticated same-origin ranges, and verify returned size',async t=>{ + const bytes=apkFixture(),hash=createHash('sha256').update(bytes).digest('hex'),requests:any[]=[]; + t.mock.method(globalThis,'fetch',async(url:URL,options:RequestInit)=>{requests.push({url:String(url),options});const range=(options.headers as any).Range;if(range){const [,a,b]=range.match(/bytes=(\d+)-(\d+)/);return new Response(bytes.slice(+a,+b+1),{status:206,headers:{'content-range':`bytes ${a}-${b}/${bytes.length}`}});}return new Response(bytes,{headers:{'content-length':String(bytes.length)}});}); + const source=officeApk({name:'Store-APK',url:'/api/dev-apk/download?floor=game&kind=store&id=fixed',bytes:bytes.length,sha256:hash},'https://office.example'); + assert.equal(await apkPackage(source,signal()),'com.example.game');assert.equal((await new Response(await source.stream(signal())).arrayBuffer()).byteLength,bytes.length);assert.ok(requests.every(r=>r.url.includes('kind=store&id=fixed')&&r.options.credentials==='same-origin'&&r.options.redirect==='error')); + assert.throws(()=>officeApk({name:'x',url:'https://other.example/a.apk',bytes:22,sha256:hash},'https://office.example'),/Ungültiger/); + t.mock.method(globalThis,'fetch',async()=>new Response(bytes,{status:200}));await assert.rejects(source.read(0,20,signal()),/nicht verfügbar/); +}); +test('successful install requires Android Success and cleans only its temporary APK',async()=>{ + const f=fakeDevice(),updates:any[]=[];assert.equal(await installApk(f.device,local(),signal(),p=>updates.push(p)),'com.example.game');assert.equal(f.pushes,1); + const install=f.commands.find(c=>c[0]==='pm')!;assert.deepEqual(install.slice(0,4),['pm','install','-r','-t']);assert.match(install[4],/^\/data\/local\/tmp\/office-apk-[\w-]+\.apk$/);assert.deepEqual(f.commands.at(-1),['rm','-f',install[4]]);assert.equal(updates.at(-1).message,'APK erfolgreich installiert.'); +}); +test('checksum mismatch prevents install; signature and partial success failures never uninstall or force downgrade',async()=>{ + const f=fakeDevice(),source=local();source.sha256='0'.repeat(64);await assert.rejects(installApk(f.device,source,signal(),()=>{}),/Prüfsumme/);assert.ok(!f.commands.some(c=>c[0]==='pm'));assert.equal(f.commands.at(-1)?.[0],'rm'); + for(const output of ['Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]','Success\nFailure [INSTALL_FAILED_VERSION_DOWNGRADE]','']){const g=fakeDevice(output);await assert.rejects(installApk(g.device,local(),signal(),()=>{}));assert.ok(!g.commands.some(c=>c.includes('uninstall')||c.includes('-d')));} + assert.match(explain(Error('INSTALL_FAILED_UPDATE_INCOMPATIBLE')),/nichts deinstalliert/); +}); +test('launch resolves the installed package and rejects package/command injection',async()=>{ + const f=fakeDevice();await launchInstalled(f.device,'com.example.game',signal());assert.deepEqual(f.commands.at(-1),['am','start','-a','android.intent.action.MAIN','-n','com.example.game/.MainActivity']);await assert.rejects(launchInstalled(f.device,'com.game;reboot',signal())); +}); +test('controller gates launch on successful install and clears it when the device disconnects',async()=>{ + const f=fakeDevice(),c=new QuestInstaller(()=>{},async()=>f.device);await c.launch();assert.equal(f.commands.length,0);await c.connect();await c.install(local());assert.equal(c.state.installed,'com.example.game');await c.launch();assert.match(c.state.message,/Spiel gestartet/);f.disconnect();await Promise.resolve();assert.equal(c.state.connected,undefined);assert.equal(c.state.installed,undefined);await c.dispose(); +}); +test('cancel/dispose abort an active upload, suppress Android install and close the device; duplicate clicks do not dispatch twice',async()=>{ + const f=fakeDevice();let started!:()=>void;const ready=new Promise(r=>started=r); + f.device.push=async(_p,_source,s)=>{started();await new Promise((_,reject)=>{s.addEventListener('abort',()=>reject(s.reason),{once:true});});}; + const c=new QuestInstaller(()=>{},async()=>f.device);await c.connect();const first=c.install(local());await ready;await c.install(local());c.cancel();await first;assert.equal(c.state.installed,undefined);assert.ok(!f.commands.some(c=>c[0]==='pm'));assert.match(c.state.error!,/Abgebrochen/);await c.dispose();assert.equal(f.closed,1); +}); diff --git a/tests/support/quest-apk.ts b/tests/support/quest-apk.ts new file mode 100644 index 000000000..b8a2c40d1 --- /dev/null +++ b/tests/support/quest-apk.ts @@ -0,0 +1,9 @@ +import { zipSync } from 'fflate'; +export function manifestFixture(name = 'com.example.game', utf8 = true) { + const values = ['manifest','package',name], chunks: Buffer[] = [], offsets: number[] = []; let bytes = 0; + for (const s of values) { const b = Buffer.from(s,utf8?'utf8':'utf16le'), length = utf8 ? Buffer.from([s.length,b.length]) : Buffer.from([s.length,0]); const entry=Buffer.concat([length,b,Buffer.alloc(utf8?1:2)]);offsets.push(bytes);bytes+=entry.length;chunks.push(entry); } + const start=28+values.length*4, pool=Buffer.alloc((start+bytes+3)&~3);pool.writeUInt16LE(1,0);pool.writeUInt16LE(28,2);pool.writeUInt32LE(pool.length,4);pool.writeUInt32LE(values.length,8);pool.writeUInt32LE(utf8?256:0,16);pool.writeUInt32LE(start,20);offsets.forEach((v,i)=>pool.writeUInt32LE(v,28+i*4));Buffer.concat(chunks).copy(pool,start); + const tag=Buffer.alloc(56);tag.writeUInt16LE(0x0102,0);tag.writeUInt16LE(16,2);tag.writeUInt32LE(56,4);tag.writeUInt32LE(0xffffffff,16);tag.writeUInt32LE(0,20);tag.writeUInt16LE(20,24);tag.writeUInt16LE(20,26);tag.writeUInt16LE(1,28);tag.writeUInt32LE(0xffffffff,36);tag.writeUInt32LE(1,40);tag.writeUInt32LE(2,44);tag.writeUInt16LE(8,48);tag[51]=3;tag.writeUInt32LE(2,52); + const head=Buffer.alloc(8);head.writeUInt16LE(3,0);head.writeUInt16LE(8,2);head.writeUInt32LE(8+pool.length+tag.length,4);return new Uint8Array(Buffer.concat([head,pool,tag])); +} +export const apkFixture = (name='com.example.game', utf8=true, level:0|6=6) => zipSync({'AndroidManifest.xml':manifestFixture(name,utf8),'classes.dex':new Uint8Array(100000)}, {level});