Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mbf-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"@types/jest": "^27.0.1",
"@types/node": "^24.2.0",
"@types/react": "^18.0.0",
"@types/w3c-web-usb": "^1.0.10",
"@types/react-dom": "^18.0.0",
"@types/semver": "^7.5.8",
"@types/w3c-web-usb": "^1.0.10",
"@vitejs/plugin-react": "^4.2.1",
"@yume-chan/adb": "workspace:^",
"@yume-chan/adb-credential-web": "workspace:^",
Expand Down
135 changes: 96 additions & 39 deletions mbf-site/src/Agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdbSync, AdbSyncWriteOptions, Adb, encodeUtf8, AdbPacketData, AdbCommand, packetListeners } from "@yume-chan/adb";
import { Consumable, TextDecoderStream, MaybeConsumable, ReadableStream } from "@yume-chan/stream-extra";
import { PromiseResolver } from '@yume-chan/async';
import { Consumable, ConcatStringStream, TextDecoderStream, MaybeConsumable, ReadableStream, WritableStream } from '@yume-chan/stream-extra';
import { Request, Response, LogMsg, ModStatus, Mods, FixedPlayerData, ImportResult, DowngradedManifest, Patched, ModSyncResult, AgentParameters } from "./Messages";
import { AGENT_SHA1 } from './agent_manifest';
import { toast } from 'react-toastify';
Expand Down Expand Up @@ -169,6 +170,46 @@ async function downloadAgent(): Promise<Uint8Array> {
throw new Error("Failed to fetch agent after multiple attempts.\nDid you lose internet connection just after you loaded the site?\n\nIf not, then please report this issue, including a screenshot of the browser console window!");
}

/**
* Creates a WritableStream that can be used to log messages from the agent.
* @returns
*/
function createLoggingWritableStream<ChunkType>(chunkCallback?: (chunks: ChunkType[], closed: boolean, error: unknown) => any): { promise: Promise<ChunkType[]>, stream: WritableStream<ChunkType> } {
let streamResolver = new PromiseResolver<ChunkType[]>();
let chunks: ChunkType[] = [];
let promiseResolved = false;

setTimeout(async () => {
await streamResolver.promise
.then(() => chunkCallback?.(chunks, true, undefined))
.catch((error) => chunkCallback?.(chunks, true, error))
.finally();

promiseResolved = true;
});

// Create a WritableStream that will log messages from the agent
const stream = new WritableStream<ChunkType>({
write(chunk) {
chunks.push(chunk);
chunkCallback?.(chunks, false, undefined);
},
close() {
chunkCallback?.(chunks, true, undefined);
!promiseResolved && streamResolver.resolve(chunks);
},
abort(error) {
chunkCallback?.(chunks, true, error);
!promiseResolved && streamResolver.reject({chunks, error});
},
});

return {
promise: streamResolver.promise,
stream
}
}

async function sendRequest(adb: Adb, request: Request): Promise<Response> {
let wrappedRequest: AgentParameters & Request = {
agent_parameters: {
Expand All @@ -178,10 +219,10 @@ async function sendRequest(adb: Adb, request: Request): Promise<Response> {
...request
}
let command_buffer = encodeUtf8(JSON.stringify(wrappedRequest) + "\n");

let agentProcess = await adb.subprocess.noneProtocol.spawn(AgentPath);

let response = null as (Response | null); // Typescript is weird...
const stdin = agentProcess.stdin.getWriter();

try {
stdin.write(new Consumable(command_buffer));
} finally {
Expand All @@ -191,37 +232,39 @@ async function sendRequest(adb: Adb, request: Request): Promise<Response> {
let exited = false;
agentProcess.exited.then(() => exited = true);
adb.disconnected.then(() => exited = true);

const reader = agentProcess.output
// TODO: Not totally sure if this will handle non-ASCII correctly.
// Doesn't seem to consider that a chunk might not be valid UTF-8 on its own
.pipeThrough(new TextDecoderStream())
.getReader();

console.group("Agent Request");
let buffer = "";
let response: Response | null = null;
while(!exited) {
const result = await reader.read();
const receivedStr = result.value;
if(receivedStr === undefined) {
continue;
}
console.log(request);

// TODO: This is fairly inefficient in terms of memory usage
// (although we aren't receiving a huge amount of data so this might be OK)
buffer += receivedStr;
const messages = buffer.split("\n");
buffer = messages[messages.length - 1];
console.group("Messages");

for(let i = 0; i < messages.length - 1; i++) {
// Parse each newline separated message as a Response
// Create a WritableStream that will log messages from the agent stdout.
// The stream will run the callback function when it receives a chunk of data.
const {stream: outputCaptureStream, promise: outputCapturePromise} = createLoggingWritableStream((chunks: string[], closed, error) => {
// Combine all the chunks into a single string, then split it by newline.
// Splice also clears the chunks array.
const messages: string[] = chunks.splice(0, chunks.length).join("").split("\n");

// If not closed, the last message is incomplete and should be put back into the chunks array
if (!closed) {
const lastMessage = messages.pop()!;
chunks.unshift(lastMessage);
}

// Parse each message
for (const message of messages.filter(m => m.trim())) {
let msg_obj: Response;

// Try to parse the message as JSON
try {
msg_obj = JSON.parse(messages[i]) as Response;
msg_obj = JSON.parse(message) as Response;
} catch(e) {
throw new Error("Agent message " + messages[i] + " was not valid JSON");
// If the message is not valid JSON, log it and throw an error
console.log(message);
throw new Error("Agent message " + message + " was not valid JSON");
}

// If the message is a log message, emit it to the global log store
if(msg_obj.type === "LogMsg") {
const log_obj = msg_obj as LogMsg;
Log.emitEvent(log_obj);
Expand All @@ -230,15 +273,30 @@ async function sendRequest(adb: Adb, request: Request): Promise<Response> {
if(msg_obj.level === 'Error') {
response = msg_obj;
}
} else {
// The final message is the only one that isn't of type `log`.
// This contains the actual response data
response = msg_obj;

continue;
}

// The final message is the only one that isn't of type `log`.
// This contains the actual response data
console.log(msg_obj);
response = msg_obj;
}
}
console.groupEnd();
});
outputCapturePromise.finally(console.groupEnd);

// Pipe the agent stdout and stderr to the logging streams
agentProcess.output.pipeThrough(new TextDecoderStream()).pipeTo(outputCaptureStream);

// Wait for everything to finish
let [exitCode, outputChunks] = await Promise.all([
agentProcess.exited,
outputCapturePromise
]);

console.log(`Exited: ${exitCode}`);
console.groupEnd();

// "None" protocol is necessary as we pass input strings longer than one line in terminal sometimes
// So using the shell protocol can cause messages to fail: particularly, when patching the game,
// we need to send the whole manifest over.
Expand All @@ -247,13 +305,12 @@ async function sendRequest(adb: Adb, request: Request): Promise<Response> {

// Don't worry too much! The agent should always return 0, even if it encounters an error, since errors are sent via a JSON message.

if(response === null) {
throw new Error("Received error response from agent");
} else if(response.type === 'LogMsg') {
const log = response as LogMsg;
throw new Error("`" + log.message + "`");
} else {
return response;
await agentProcess.exited;

if(response !== null && (response as LogMsg).type === 'LogMsg') {
throw new Error("Agent responded with an error", { cause: response });
} else {
return response as Response;
}
}

Expand Down
147 changes: 27 additions & 120 deletions mbf-site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,87 +13,29 @@ import 'react-toastify/dist/ReactToastify.css';
import { CornerMenu } from './components/CornerMenu';
import { installLoggers, setCoreModOverrideUrl } from './Agent';
import { Log } from './Logging';
import { OperationModals } from './components/OperationModals';
import { OpenLogsButton } from './components/OpenLogsButton';
import { isViewingOnIos, isViewingOnMobile, isViewingOnWindows, usingOculusBrowser } from './platformDetection';
import { SourceUrl } from '.';
import { useDeviceStore } from './DeviceStore';

type NoDeviceCause = "NoDeviceSelected" | "DeviceInUse";

const NON_LEGACY_ANDROID_VERSION: number = 11;

async function connect(
setAuthing: () => void): Promise<Adb | NoDeviceCause> {
const device_manager = new AdbDaemonWebUsbDeviceManager(navigator.usb);
const quest = await device_manager.requestDevice();
if(quest === undefined) {
return "NoDeviceSelected";
}

let connection: AdbDaemonWebUsbConnection;
try {
if(import.meta.env.DEV) {
Log.debug("Developer build detected, attempting to disconnect ADB server before connecting to quest");
await tryDisconnectAdb();
}

connection = await quest.connect();
installLoggers();
} catch(err) {
if(String(err).includes("The device is already in used")) {
Log.warn("Full interface error: " + err);
// Some other ADB daemon is hogging the connection, so we can't get to the Quest.
return "DeviceInUse";
} else {
throw err;
}
}
const keyStore: AdbWebCredentialStore = new AdbWebCredentialStore("ModsBeforeFriday");

setAuthing();
const transport: AdbDaemonTransport = await AdbDaemonTransport.authenticate({
serial: quest.serial,
connection,
credentialStore: keyStore
});

return new Adb(transport);
}
import { useDeviceConnector } from './hooks/DeviceConnector';
import { OperationModals } from './components/OperationModals';

// Attempts to invoke mbf-adb-killer to disconnect the ADB server, avoiding the developer working on MBF having to manually do this.
async function tryDisconnectAdb() {
try {
await fetch("http://localhost:25898");
} catch {
Log.warn("ADB killer is not running. ADB will have to be killed manually");
}
}

export async function getAndroidVersion(device: Adb) {
return Number((await device.subprocess.noneProtocol.spawnWaitText("getprop ro.build.version.release")));
}

function ChooseDevice() {
const [authing, setAuthing] = useState(false);
const [connectError, setConnectError] = useState(null as string | null);
const [deviceInUse, setDeviceInUse] = useState(false);
const {
devicePreV51, setDevicePreV51,
device: chosenDevice, setDevice: setChosenDevice,
androidVersion, setAndroidVersion
} = useDeviceStore();

const { devicePreV51, deviceInUse, authing, chosenDevice, connecting, connectError, connectDevice, disconnectDevice, DeviceConnectorContextProvider } = useDeviceConnector(null);
const [modderError, setModderError] = useState<string | null>(null);

if(chosenDevice !== null) {
return <>
<DeviceModder device={chosenDevice} devicePreV51={devicePreV51} quit={(err) => {
if(err != null) {
setConnectError(String(err));
}
chosenDevice.close().catch(err => Log.warn("Failed to close device " + err));
setChosenDevice(null);
}} />
</>
return (
<DeviceConnectorContextProvider>
<DeviceModder quit={(err) => {
if (err != null) {
setModderError(String(err));
}
disconnectDevice();
}
} />
</DeviceConnectorContextProvider>
)
} else if(authing) {
return <div className='container mainContainer fadeIn'>
<h2>Allow connection in headset</h2>
Expand All @@ -110,6 +52,7 @@ function ChooseDevice() {
</div>
} else {
return <>
<DeviceConnectorContextProvider>
<div className="container mainContainer">
<Title />
<p>To get started, plug your Quest in with a USB-C cable and click the button below.</p>
Expand All @@ -119,60 +62,24 @@ function ChooseDevice() {

<div className="chooseDeviceContainer">
<span><OpenLogsButton /></span>
<button onClick={async () => {
let device: Adb | null;

try {
const result = await connect(() => setAuthing(true));
if(result === "NoDeviceSelected") {
device = null;
} else if(result === "DeviceInUse") {
setDeviceInUse(true);
return;
} else {
device = result;

const androidVersion = await getAndroidVersion(device);
setAndroidVersion(androidVersion);

Log.debug("Device android version: " + androidVersion);

const deviceName = device.banner.model;
if (deviceName === "Quest") {
Log.debug("Device is a Quest 1, switching to pre-v51 mode");
setDevicePreV51(androidVersion < NON_LEGACY_ANDROID_VERSION);
}

setAuthing(false);
setChosenDevice(device);

await device.transport.disconnected;
setChosenDevice(null);
}

} catch(error) {
Log.error("Failed to connect: " + error);
setConnectError(String(error));
setChosenDevice(null);
return;
}
}}>Connect to Quest</button>
<button onClick={() => !connecting && connectDevice()}>Connect to Quest</button>
</div>

<ErrorModal isVisible={connectError != null}
{connectError && <ErrorModal isVisible={true}
title="Failed to connect to device"
description={connectError}
onClose={() => setConnectError(null)}>
onClose={() => disconnectDevice()}>
<AskLaurie />
</ErrorModal>
</ErrorModal>}

<ErrorModal isVisible={deviceInUse}
onClose={() => setDeviceInUse(false)}
{deviceInUse && <ErrorModal isVisible={true}
onClose={() => disconnectDevice()}
title="Device in use">
<DeviceInUse />
</ErrorModal>
</ErrorModal>}
</div>
</>
</DeviceConnectorContextProvider>
</>
}
}

Expand Down Expand Up @@ -267,7 +174,7 @@ function AppContents() {
}
}

function App() {
function App() {
return <div className='main'>
<AppContents />
<CornerMenu />
Expand Down
Loading