11#!/usr/bin/env node
22
33import { execFileSync } from "node:child_process" ;
4- import { mkdtempSync , rmSync , unlinkSync , writeFileSync } from "node:fs" ;
4+ import {
5+ existsSync ,
6+ mkdtempSync ,
7+ renameSync ,
8+ rmSync ,
9+ unlinkSync ,
10+ writeFileSync ,
11+ } from "node:fs" ;
512import { tmpdir } from "node:os" ;
613import { dirname , resolve } from "node:path" ;
714import { pathToFileURL } from "node:url" ;
815
916export const PACKAGE = "com.tommybuilds.shock2quest" ;
1017export const ACTIVITY = `${ PACKAGE } /android.app.NativeActivity` ;
1118export const MISSION_CONFIG = "/sdcard/shock2quest/vr-mission.txt" ;
19+ export const DEFAULT_MISSION = "medsci1.mis" ;
1220
1321const delay = ( milliseconds ) =>
1422 new Promise ( ( resolveDelay ) => setTimeout ( resolveDelay , milliseconds ) ) ;
@@ -453,6 +461,104 @@ export function deviceStatus(serial) {
453461 } ;
454462}
455463
464+ // Uploaded by .github/workflows/build-android.yml.
465+ export function apkArtifactName ( sha ) {
466+ return `shock2quest-apk-${ sha } ` ;
467+ }
468+
469+ // Picks a successful Build Android run from `gh run list --commit SHA` rows
470+ // (newest first); otherwise throws, so a stale build is never installed.
471+ export function selectBuildRun ( runs , sha ) {
472+ const succeeded = runs . find (
473+ ( run ) => run . status === "completed" && run . conclusion === "success" ,
474+ ) ;
475+ if ( succeeded ) return succeeded ;
476+ const latest = runs [ 0 ] ;
477+ if ( ! latest ) {
478+ throw new Error (
479+ `no Build Android run for ${ sha } (markdown-only pushes skip it)` ,
480+ ) ;
481+ }
482+ if ( latest . status !== "completed" ) {
483+ throw new Error ( `Build Android for ${ sha } is still ${ latest . status } : ${ latest . url } ` ) ;
484+ }
485+ throw new Error ( `Build Android for ${ sha } ended ${ latest . conclusion } : ${ latest . url } ` ) ;
486+ }
487+
488+ // A CI APK (release key) cannot update a locally built one (debug key).
489+ export function isSignatureMismatch ( output ) {
490+ return output . includes ( "INSTALL_FAILED_UPDATE_INCOMPATIBLE" ) ;
491+ }
492+
493+ function gh ( args ) {
494+ return execFileSync ( "gh" , args , {
495+ encoding : "utf8" ,
496+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
497+ } ) ;
498+ }
499+
500+ // Downloads PR NUMBER's CI APK into a per-SHA temp cache; returns its path.
501+ export function fetchPrApk ( number ) {
502+ const { headRefOid : sha , headRefName } = JSON . parse (
503+ gh ( [ "pr" , "view" , number , "--json" , "headRefOid,headRefName" ] ) ,
504+ ) ;
505+ const cacheDirectory = resolve ( tmpdir ( ) , "shock2quest-pr-apk" , sha ) ;
506+ const apkPath = resolve ( cacheDirectory , "shock2quest.apk" ) ;
507+ process . stderr . write ( `PR #${ number } ${ headRefName } @ ${ sha } \n` ) ;
508+ if ( existsSync ( apkPath ) ) return apkPath ;
509+
510+ const runs = JSON . parse (
511+ gh ( [
512+ "run" ,
513+ "list" ,
514+ "--workflow" ,
515+ "build-android.yml" ,
516+ "--commit" ,
517+ sha ,
518+ "--json" ,
519+ "databaseId,status,conclusion,url" ,
520+ ] ) ,
521+ ) ;
522+ const run = selectBuildRun ( runs , sha ) ;
523+ // Download beside the cache so an interrupted download is never reused.
524+ const partial = `${ cacheDirectory } .partial` ;
525+ rmSync ( partial , { recursive : true , force : true } ) ;
526+ gh ( [
527+ "run" ,
528+ "download" ,
529+ String ( run . databaseId ) ,
530+ "-n" ,
531+ apkArtifactName ( sha ) ,
532+ "-D" ,
533+ partial ,
534+ ] ) ;
535+ renameSync ( partial , cacheDirectory ) ;
536+ return apkPath ;
537+ }
538+
539+ export function installApk ( serial , apkPath ) {
540+ // -g grants storage up front; a fresh install would otherwise prompt.
541+ const install = ( ) => adb ( serial , [ "install" , "-r" , "-g" , apkPath ] ) ;
542+ try {
543+ install ( ) ;
544+ } catch ( error ) {
545+ const output = `${ error . message } ${ error . stdout ?? "" } ${ error . stderr ?? "" } ` ;
546+ if ( ! isSignatureMismatch ( output ) ) throw error ;
547+ process . stderr . write (
548+ "installed APK has another signature; uninstalling it first (this deletes its app-private saves)\n" ,
549+ ) ;
550+ adb ( serial , [ "uninstall" , PACKAGE ] ) ;
551+ install ( ) ;
552+ }
553+ }
554+
555+ function takeOption ( args , name ) {
556+ const index = args . indexOf ( name ) ;
557+ if ( index === - 1 ) return undefined ;
558+ const [ , value ] = args . splice ( index , 2 ) ;
559+ return value ;
560+ }
561+
456562function usage ( ) {
457563 return `Usage:
458564 quest-device.mjs [--serial SERIAL] status
@@ -463,17 +569,13 @@ function usage() {
463569 quest-device.mjs [--serial SERIAL] record OUTPUT.mp4 [SECONDS]
464570 quest-device.mjs [--serial SERIAL] stop
465571 quest-device.mjs [--serial SERIAL] restore
466- quest-device.mjs [--serial SERIAL] reset-mission` ;
572+ quest-device.mjs [--serial SERIAL] reset-mission
573+ quest-device.mjs pr NUMBER [--mission MISSION] [--serial SERIAL]` ;
467574}
468575
469576async function main ( argv ) {
470577 const args = [ ...argv ] ;
471- let explicitSerial ;
472- if ( args [ 0 ] === "--serial" ) {
473- explicitSerial = args [ 1 ] ;
474- args . splice ( 0 , 2 ) ;
475- }
476- const serial = resolveSerial ( explicitSerial ) ;
578+ const serial = resolveSerial ( takeOption ( args , "--serial" ) ) ;
477579 const [ command , ...values ] = args ;
478580
479581 if ( command === "status" ) {
@@ -497,6 +599,13 @@ async function main(argv) {
497599 } else if ( command === "record" && values [ 0 ] ) {
498600 const seconds = values [ 1 ] === undefined ? 8 : Number ( values [ 1 ] ) ;
499601 process . stdout . write ( `${ recordVideo ( serial , values [ 0 ] , seconds ) } \n` ) ;
602+ } else if ( command === "pr" && / ^ \d + $ / . test ( values [ 0 ] ?? "" ) ) {
603+ const mission = validateMission (
604+ takeOption ( values , "--mission" ) ?? DEFAULT_MISSION ,
605+ ) ;
606+ installApk ( serial , fetchPrApk ( values [ 0 ] ) ) ;
607+ const { logs : _logs , ...summary } = await launchMission ( serial , mission ) ;
608+ process . stdout . write ( `${ JSON . stringify ( summary , null , 2 ) } \n` ) ;
500609 } else if ( command === "stop" ) {
501610 stopApp ( serial ) ;
502611 } else if ( command === "restore" ) {
0 commit comments