diff --git a/mbf-site/package.json b/mbf-site/package.json index b6b42597..21736850 100644 --- a/mbf-site/package.json +++ b/mbf-site/package.json @@ -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:^", diff --git a/mbf-site/src/Agent.ts b/mbf-site/src/Agent.ts index 85ebada1..96116911 100644 --- a/mbf-site/src/Agent.ts +++ b/mbf-site/src/Agent.ts @@ -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'; @@ -169,6 +170,46 @@ async function downloadAgent(): Promise { 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(chunkCallback?: (chunks: ChunkType[], closed: boolean, error: unknown) => any): { promise: Promise, stream: WritableStream } { + let streamResolver = new PromiseResolver(); + 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({ + 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 { let wrappedRequest: AgentParameters & Request = { agent_parameters: { @@ -178,10 +219,10 @@ async function sendRequest(adb: Adb, request: Request): Promise { ...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 { @@ -191,37 +232,39 @@ async function sendRequest(adb: Adb, request: Request): Promise { 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); @@ -230,15 +273,30 @@ async function sendRequest(adb: Adb, request: Request): Promise { 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. @@ -247,13 +305,12 @@ async function sendRequest(adb: Adb, request: Request): Promise { // 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; } } diff --git a/mbf-site/src/App.tsx b/mbf-site/src/App.tsx index 1b8e9508..2defb81b 100644 --- a/mbf-site/src/App.tsx +++ b/mbf-site/src/App.tsx @@ -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 { - 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(null); + if(chosenDevice !== null) { - return <> - { - if(err != null) { - setConnectError(String(err)); - } - chosenDevice.close().catch(err => Log.warn("Failed to close device " + err)); - setChosenDevice(null); - }} /> - + return ( + + { + if (err != null) { + setModderError(String(err)); + } + disconnectDevice(); + } + } /> + + ) } else if(authing) { return

Allow connection in headset

@@ -110,6 +52,7 @@ function ChooseDevice() {
} else { return <> +
<p>To get started, plug your Quest in with a USB-C cable and click the button below.</p> @@ -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> + </> } } @@ -267,7 +174,7 @@ function AppContents() { } } -function App() { +function App() { return <div className='main'> <AppContents /> <CornerMenu /> diff --git a/mbf-site/src/DeviceModder.tsx b/mbf-site/src/DeviceModder.tsx index 0bbdc6aa..846a825e 100644 --- a/mbf-site/src/DeviceModder.tsx +++ b/mbf-site/src/DeviceModder.tsx @@ -11,15 +11,13 @@ import { PermissionsMenu } from './components/PermissionsMenu'; import { SelectableList } from './components/SelectableList'; import { AndroidManifest } from './AndroidManifest'; import { Log } from './Logging'; -import { wrapOperation } from './SyncStore'; import { OpenLogsButton } from './components/OpenLogsButton'; import { lte as semverLte } from 'semver'; -import { useDeviceStore } from './DeviceStore'; import { gameId } from './game_info'; +import { useDeviceConnectorContext } from './hooks/DeviceConnector'; +import { OperationModals } from './components/OperationModals'; interface DeviceModderProps { - device: Adb, - devicePreV51: boolean, // Quits back to the main menu, optionally giving an error that caused the quit. quit: (err: unknown | null) => void } @@ -70,14 +68,14 @@ export function CompareBeatSaberVersions(a: string, b: string): number { export function DeviceModder(props: DeviceModderProps) { const [modStatus, setModStatus] = useState(null as ModStatus | null); const { quit } = props; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); useEffect(() => { - if (!device) { return; } // If the device is not set, do not attempt to load mod status. - loadModStatus(device) + if (!chosenDevice) { return; } // If the device is not set, do not attempt to load mod status. + loadModStatus(chosenDevice) .then(loadedModStatus => setModStatus(loadedModStatus)) .catch(err => quit(err)); - }, [device]); + }, [chosenDevice]); // Fun "ocean" of IF statements, hopefully covering every possible state of an installation! if (modStatus === null) { @@ -150,16 +148,16 @@ export function DeviceModder(props: DeviceModderProps) { } function NoObb({ quit }: { quit: () => void }) { - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); return <div className="container mainContainer"> <h1>OBB not present</h1> <p>MBF has detected that the OBB file, which contains asset files required for Beat Saber to load, is not present in the installation.</p> <p>This means your installation is corrupt. You will need to uninstall Beat Saber with the button below, and reinstall the latest version from the Meta store.</p> <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await uninstallBeatSaber(device); + await uninstallBeatSaber(chosenDevice); quit(); }}>Uninstall Beat Saber</button> </div> @@ -169,7 +167,7 @@ function ValidModLoaderMenu({ modStatus, setModStatus, quit }: { modStatus: ModStatus, setModStatus: (status: ModStatus) => void quit: () => void}) { - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); return <> <div className='container mainContainer'> @@ -219,7 +217,7 @@ function InstallStatus(props: InstallStatusProps) { const modloaderStatus = modStatus.modloader_install_status; const coreModStatus = modStatus.core_mods!.core_mod_install_status; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); if (modloaderStatus === "Ready" && coreModStatus === "Ready") { return <p>Everything should be ready to go! ✅</p> @@ -238,10 +236,10 @@ function InstallStatus(props: InstallStatusProps) { <li>Core mod updates need to be installed.</li>} </ul> <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - wrapOperation("Fixing issues", "Failed to fix install", async () => - onFixed(await quickFix(device, modStatus, false))); + OperationModals.wrapOperation("Fixing issues", "Failed to fix install", async () => + onFixed(await quickFix(chosenDevice, modStatus, false))); }}>Fix issues</button> </div> } @@ -250,7 +248,7 @@ function InstallStatus(props: InstallStatusProps) { function UpdateInfo({ modStatus, quit }: { modStatus: ModStatus, quit: () => void }) { const sortedModdableVersions = modStatus.core_mods!.supported_versions.sort(CompareBeatSaberVersions); const newerUpdateExists = modStatus.app_info?.version !== sortedModdableVersions[0]; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); const [updateWindowOpen, setUpdateWindowOpen] = useState(false); @@ -267,8 +265,8 @@ function UpdateInfo({ modStatus, quit }: { modStatus: ModStatus, quit: () => voi <li>Open back up MBF to mod the version you just installed.</li> </ol> <button onClick={async () => { - if (!device) return; - await uninstallBeatSaber(device); + if (!chosenDevice) return; + await uninstallBeatSaber(chosenDevice); quit(); }}>Uninstall Beat Saber</button> <button onClick={() => setUpdateWindowOpen(false)} className="discreetButton">Cancel</button> @@ -298,7 +296,7 @@ function PatchingMenu(props: PatchingMenuProps) { const [versionOverridden, setVersionOverridden] = useState(false); const { onCompleted, modStatus, initialDowngradingTo } = props; - const { device, devicePreV51 } = useDeviceStore((state) => ({ device: state.device, devicePreV51: state.devicePreV51 })); + const { chosenDevice, devicePreV51 } = useDeviceConnectorContext(); const [downgradingTo, setDowngradingTo] = useState(initialDowngradingTo); const downgradeChoices = GetSortedDowngradableVersions(modStatus)! .filter(version => version != initialDowngradingTo); @@ -307,12 +305,12 @@ function PatchingMenu(props: PatchingMenuProps) { manifest?.applyPatchingManifestMod(devicePreV51); useEffect(() => { - if (!device) return; + if (!chosenDevice) return; if(downgradingTo === null) { setManifest(new AndroidManifest(props.modStatus.app_info!.manifest_xml)); } else { - getDowngradedManifest(device, downgradingTo) + getDowngradedManifest(chosenDevice, downgradingTo) .then(manifest_xml => setManifest(new AndroidManifest(manifest_xml))) .catch(error => { // TODO: Perhaps revert to "not downgrading" if this error comes up (but only if the latest version is moddable) @@ -363,12 +361,12 @@ function PatchingMenu(props: PatchingMenuProps) { <p>Mods and custom songs are not supported by Beat Games. You may experience bugs and crashes that you wouldn't in a vanilla game.</p> <div> <button className="discreetButton" id="permissionsButton" onClick={() => setSelectingPerms(true)}>Permissions</button> - <button disabled={!device} className="largeCenteredButton" onClick={async () => { - if (!device) return; + <button disabled={!chosenDevice} className="largeCenteredButton" onClick={async () => { + if (!chosenDevice) return; setIsPatching(true); try { - onCompleted(await patchApp(device, modStatus, downgradingTo, manifest.toString(), false, isDeveloperUrl, devicePreV51, null)); + onCompleted(await patchApp(chosenDevice, modStatus, downgradingTo, manifest.toString(), false, isDeveloperUrl, devicePreV51, null)); } catch (e) { setPatchingError(String(e)); setIsPatching(false); @@ -473,7 +471,7 @@ interface IncompatibleLoaderProps { } function NotSupported({ version, quit }: { version: string, quit: () => void }) { - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); const isLegacy = isVersionLegacy(version); return <div className='container mainContainer'> @@ -495,9 +493,9 @@ function NotSupported({ version, quit }: { version: string, quit: () => void }) </>} <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await uninstallBeatSaber(device); + await uninstallBeatSaber(chosenDevice); quit(); }}>Uninstall Beat Saber</button> </div> @@ -524,7 +522,7 @@ function NoDiffAvailable({ version }: { version: string }) { function IncompatibleLoader(props: IncompatibleLoaderProps) { const { loader, quit } = props; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); return <div className='container mainContainer'> <h1>Incompatible Modloader</h1> @@ -533,9 +531,9 @@ function IncompatibleLoader(props: IncompatibleLoaderProps) { <p>Do not be alarmed! Your custom songs will not be lost.</p> <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await uninstallBeatSaber(device); + await uninstallBeatSaber(chosenDevice); quit(); }}>Uninstall Beat Saber</button> </div> @@ -544,7 +542,7 @@ function IncompatibleLoader(props: IncompatibleLoaderProps) { function IncompatibleAlreadyModded({ quit, installedVersion }: { quit: () => void, installedVersion: string }) { - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); return <div className='container mainContainer'> <h1>Incompatible Version Patched</h1> @@ -553,9 +551,9 @@ function IncompatibleAlreadyModded({ quit, installedVersion }: { <p>To fix this, uninstall Beat Saber and reinstall the latest version. MBF can then downgrade this automatically to the latest moddable version.</p> <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await uninstallBeatSaber(device); + await uninstallBeatSaber(chosenDevice); quit(); }}>Uninstall Beat Saber</button> </div> diff --git a/mbf-site/src/DeviceStore.ts b/mbf-site/src/DeviceStore.ts deleted file mode 100644 index 1dd70e07..00000000 --- a/mbf-site/src/DeviceStore.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Adb } from '@yume-chan/adb'; -import { create } from "zustand"; -/** - * Device store is used to save the data about the device and a reference to the ADB connection. - */ - -export interface DeviceStore { - /** - * The ADB connection to the device. - */ - device: Adb | null; - /** - * Is the device preV51? (Quest 1) - */ - devicePreV51 : boolean; - /** - * The device name, if available. - */ - androidVersion: number | null; - setDevicePreV51: (isPreV51: boolean) => void; - setAndroidVersion: (version: number | null) => void; - setDevice: (adb: Adb | null) => void; - -} - -export const useDeviceStore = create<DeviceStore>(set => ({ - device: null, - devicePreV51: false, - androidVersion: null, - Adb: null, - setDevicePreV51: (isPreV51: boolean) => set(() => ({ devicePreV51: isPreV51 })), - setAndroidVersion: (version: number | null) => set(() => ({ androidVersion: version })), - setDevice: (device: Adb | null) => set(() => ({ device: device })) -})); \ No newline at end of file diff --git a/mbf-site/src/SyncStore.ts b/mbf-site/src/SyncStore.ts deleted file mode 100644 index 24f59e39..00000000 --- a/mbf-site/src/SyncStore.ts +++ /dev/null @@ -1,95 +0,0 @@ - - -// Used to keep track of the current operation that MBF is carrying out. -// "Operations" are mutually exclusive, so e.g. if the app permissions are being changed, -// we cannot have a mod installation in progress, for example. -import { create } from "zustand"; -import { Log } from "./Logging"; - -// An error that occured within a particular operation. -// -export interface OperationError { - title: string, - error: string -} - -export interface SyncStore { - // Name of the current ongoing operation - currentOperation: string | null; - // Progress/status text for the ongoing operation - statusText: string | null; - currentError: OperationError | null, - // Whether or not the logs have been manually opened by the user. - logsManuallyOpen: boolean, - // Whether to open a modal during sync. - // Before the modding status is loaded, the progress of the operation - // is shown in the main UI instead of in a modal. Therefore, this property is set to `false` - // to avoid covering this with a modal. - showSyncModal: boolean, - - setOperation: (operation: string | null) => void, - setError: (error: OperationError | null) => void, - setLogsManuallyOpen: (manuallyOpen: boolean) => void, - setStatusText: (text: string | null) => void, -} - -export const useSyncStore = create<SyncStore>(set => ({ - currentOperation: null, - currentError: null, - logsManuallyOpen: false, - showSyncModal: false, - statusText: null, - setOperation: (operation: string | null) => set(_ => ({ currentOperation: operation })), - setError: (error: OperationError | null) => set(_ => ({ currentError: error })), - setLogsManuallyOpen: (manuallyOpen: boolean) => set(_ => ({ logsManuallyOpen: manuallyOpen })), - setStatusText: (text: string | null) => set(_ => ({ statusText: text })) -})); - -// Creates a function that can be used to set whether or not a particular operation is currently in progress. -export function useSetWorking(operationName: string): (working: boolean) => void { - const { setOperation, setStatusText } = useSyncStore.getState(); - - return working => { - if(working) { - setOperation(operationName); - } else { - setStatusText(null); - setOperation(null); - } - } -} - -// Creates a function that can be used to set an error when a particular operation failed. -export function useSetError(errorTitle: string): (error: unknown | null) => void { - const { setError } = useSyncStore.getState(); - - return error => { - if(error === null) { - setError(null); - } else { - Log.error(errorTitle + ": " + String(error)); - setError({ - title: errorTitle, - error: String(error) - }) - } - } -} - -// Used to wrap a particular operation while displaying the logging window and any errors if appropriate. -export async function wrapOperation(operationName: string, - errorModalTitle: string, - operation: () => Promise<void>) { - const setWorking = useSetWorking(operationName); - const setError = useSetError(errorModalTitle); - - setWorking(true); - try { - await operation(); - } catch(error) { - Log.error(errorModalTitle + ": " + error); - setError(error); - } finally { - setWorking(false); - } -} \ No newline at end of file diff --git a/mbf-site/src/components/ModManager.tsx b/mbf-site/src/components/ModManager.tsx index 5c464735..8ec8043a 100644 --- a/mbf-site/src/components/ModManager.tsx +++ b/mbf-site/src/components/ModManager.tsx @@ -13,10 +13,10 @@ import { ImportResult, ImportedMod, ModStatus } from "../Messages"; import { OptionsMenu } from "./OptionsMenu"; import useFileDropper from "../hooks/useFileDropper"; import { Log } from "../Logging"; -import { useSetWorking, useSyncStore, wrapOperation } from "../SyncStore"; import { ModRepoMod } from "../ModsRepo"; -import { useDeviceStore } from "../DeviceStore"; import SyncIcon from "../icons/sync.svg" +import { useDeviceConnectorContext } from '../hooks/DeviceConnector'; +import { OperationModals } from "./OperationModals"; interface ModManagerProps { @@ -103,17 +103,17 @@ function InstalledModsMenu(props: ModMenuProps) { setMods, gameVersion } = props; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); const [changes, setChanges] = useState({} as { [id: string]: boolean }); return <div className={`installedModsMenu fadeIn ${props.visible ? "" : "hidden"}`}> {Object.keys(changes).length > 0 && <button className={`syncChanges fadeIn ${props.visible ? "" : "hidden"}`} onClick={async () => { - if (!device) return; + if (!chosenDevice) return; setChanges({}); Log.debug("Installing mods, statuses requested: " + JSON.stringify(changes)); - await wrapOperation("Syncing mods", "Failed to sync mods", async () => { - const modSyncResult = await setModStatuses(device, changes); + await OperationModals.wrapOperation("Syncing mods", "Failed to sync mods", async () => { + const modSyncResult = await setModStatuses(chosenDevice, changes); setMods(modSyncResult.installed_mods); if(modSyncResult.failures !== null) { @@ -133,10 +133,10 @@ function InstalledModsMenu(props: ModMenuProps) { pendingChange={changes[mod.id]} key={mod.id} onRemoved={async () => { - if (!device) return; + if (!chosenDevice) return; - await wrapOperation("Removing mod", "Failed to remove mod", async () => { - setMods(await removeMod(device, mod.id)); + await OperationModals.wrapOperation("Removing mod", "Failed to remove mod", async () => { + setMods(await removeMod(chosenDevice, mod.id)); const newChanges = { ...changes }; delete newChanges[mod.id]; @@ -213,12 +213,12 @@ function AddModsMenu(props: ModMenuProps) { setMods, gameVersion } = props; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); // Automatically installs a mod when it is imported, or warns the user if it isn't designed for the current game version. // Gives appropriate toasts/reports errors in each case. async function onModImported(result: ImportedMod) { - if (!device) return; + if (!chosenDevice) return; const { installed_mods, imported_id } = result; setMods(installed_mods); @@ -231,7 +231,7 @@ function AddModsMenu(props: ModMenuProps) { + trimGameVersion(gameVersion) + ".", { autoClose: false }); } else { try { - const result = await setModStatuses(device, { [imported_id]: true }); + const result = await setModStatuses(chosenDevice, { [imported_id]: true }); setMods(result.installed_mods); // This is where typical mod install failures occur @@ -265,10 +265,10 @@ function AddModsMenu(props: ModMenuProps) { } async function handleFileImport(file: File) { - if (!device) return; + if (!chosenDevice) return; try { - const importResult = await importFile(device, file); + const importResult = await importFile(chosenDevice, file); await onImportResult(importResult); } catch(e) { toast.error("Failed to import file: " + e); @@ -276,13 +276,13 @@ function AddModsMenu(props: ModMenuProps) { } async function handleUrlImport(url: string) { - if (!device) return; + if (!chosenDevice) return; if (url.startsWith("file:///")) { toast.error("Cannot process dropped file from this source, drag from the file picker instead. (Drag from OperaGX file downloads popup does not work)"); return; } try { - const importResult = await importUrl(device, url) + const importResult = await importUrl(chosenDevice, url) await onImportResult(importResult); } catch(e) { toast.error(`Failed to import file: ${e}`); @@ -290,7 +290,7 @@ function AddModsMenu(props: ModMenuProps) { } async function enqueueImports(imports: QueuedImport[]) { - if (!device) return; + if (!chosenDevice) return; // Add the new imports to the queue importQueue.push(...imports); @@ -304,9 +304,8 @@ function AddModsMenu(props: ModMenuProps) { isProcessingQueue = true; let disconnected = false; - device.disconnected.then(() => disconnected = true); - const setWorking = useSetWorking("Importing"); - const { setStatusText } = useSyncStore.getState(); + chosenDevice.disconnected.then(() => disconnected = true); + const setWorking = OperationModals.useSetWorking("Importing"); setWorking(true); while(importQueue.length > 0 && !disconnected) { @@ -315,17 +314,17 @@ function AddModsMenu(props: ModMenuProps) { if(newImport.type == "File") { const file = (newImport as QueuedFileImport).file; - setStatusText(`Processing file ${file.name}`); + OperationModals.statusText = `Processing file ${file.name}`; await handleFileImport(file); } else if(newImport.type == "Url") { const url = (newImport as QueuedUrlImport).url; - setStatusText(`Processing url ${url}`); + OperationModals.statusText = `Processing url ${url}`; await handleUrlImport(url); } else if(newImport.type == "ModRepo") { const mod = (newImport as QueuedModRepoImport).mod; - setStatusText(`Installing ${mod.name} v${mod.version}`); + OperationModals.statusText = `Installing ${mod.name} v${mod.version}`; await handleUrlImport(mod.download); } diff --git a/mbf-site/src/components/OpenLogsButton.tsx b/mbf-site/src/components/OpenLogsButton.tsx index 0b621d67..7647c177 100644 --- a/mbf-site/src/components/OpenLogsButton.tsx +++ b/mbf-site/src/components/OpenLogsButton.tsx @@ -1,14 +1,13 @@ import '../css/OpenLogs.css'; import LogsIcon from '../icons/logs.svg'; -import { useSyncStore } from '../SyncStore'; import { LabelledIconButton } from './LabelledIconButton'; +import { OperationModals } from './OperationModals'; export function OpenLogsButton() { - const { setLogsManuallyOpen } = useSyncStore(); return <div className="openLogs"> <LabelledIconButton iconSrc={LogsIcon} iconAlt="Piece of paper with lines of text" label="Logs" - onClick={() => setLogsManuallyOpen(true)}/> + onClick={() => OperationModals.logsManuallyOpen = true}/> </div> } \ No newline at end of file diff --git a/mbf-site/src/components/OperationModals.tsx b/mbf-site/src/components/OperationModals.tsx index 8fe730f7..befbc946 100644 --- a/mbf-site/src/components/OperationModals.tsx +++ b/mbf-site/src/components/OperationModals.tsx @@ -1,57 +1,227 @@ +/** + * @file OperationModals.tsx + * + * Used to keep track of the current operation that MBF is carrying out. + * "Operations" are mutually exclusive, so e.g. if the app permissions are being changed, + * we cannot have a mod installation in progress, for example. + */ + import { ScaleLoader } from "react-spinners"; -import { useSyncStore } from "../SyncStore"; import { LogWindow, LogWindowControls } from "./LogWindow"; import { ErrorModal } from "./Modal"; +import React, { useLayoutEffect } from "react"; +import { Log } from "../Logging"; + +/** + * An error that occured within a particular operation. + */ +export interface OperationError { + title: string; + error: string; +} + +const state: OperationModalsData = ({ + currentOperation: null, + currentError: null, + statusText: null, + logsManuallyOpen: false, + setCurrentOperation: undefined, + setCurrentError: undefined, + setStatusText: undefined, + setLogsManuallyOpen: undefined, + mounted: false, +}); + +interface OperationModalsData { + /** Name of the current ongoing operation */ + currentOperation: string | null; + currentError: OperationError | null; + + /** Progress/status text for the ongoing operation */ + statusText: string | null; + + /** Whether or not the logs have been manually opened by the user. */ + logsManuallyOpen: boolean; + + mounted: boolean; + + setCurrentOperation?: React.Dispatch<React.SetStateAction<string | null>>; + setCurrentError?: React.Dispatch<React.SetStateAction<OperationError | null>>; + setStatusText?: React.Dispatch<React.SetStateAction<string | null>>; + setLogsManuallyOpen?: React.Dispatch<React.SetStateAction<boolean>>; +} // Component that displays the log window when an operation is in progress, and displays errors when the operation failed. export function OperationModals() { - const { currentOperation, - currentError, - statusText, - setError, - logsManuallyOpen, - setLogsManuallyOpen } = useSyncStore(); - - const canClose = logsManuallyOpen && currentError === null; - const needSyncModal = (logsManuallyOpen || currentOperation !== null) - && currentError === null; - - return <> - <SyncingModal isVisible={needSyncModal} - title={currentOperation ?? "Log output"} - subtext={statusText} - onClose={canClose ? () => setLogsManuallyOpen(false) : undefined} /> - <ErrorModal isVisible={currentError !== null} - title={currentError?.title ?? ""} - description={currentError?.error} - onClose={() => setError(null)}> - </ErrorModal> + const [currentOperation, setCurrentOperation] = React.useState<string | null>(null); + const [currentError, setCurrentError] = React.useState<OperationError | null>(null); + const [statusText, setStatusText] = React.useState<string | null>(null); + const [logsManuallyOpen, setLogsManuallyOpen] = React.useState<boolean>(false); + + state.currentOperation = currentOperation; + state.currentError = currentError; + state.statusText = statusText; + state.logsManuallyOpen = logsManuallyOpen; + + useLayoutEffect(() => { + if (state.mounted) { + throw new Error("Multiple OperationModals components mounted. There should only be one."); + } + + state.setCurrentOperation = setCurrentOperation; + state.setCurrentError = setCurrentError; + state.setStatusText = setStatusText; + state.setLogsManuallyOpen = setLogsManuallyOpen; + state.mounted = true; + + return () => { + state.setCurrentOperation = undefined; + state.setCurrentError = undefined; + state.setStatusText = undefined; + state.setLogsManuallyOpen = undefined; + state.mounted = false; + }; + }); + + const canClose = logsManuallyOpen && currentError === null; + const needSyncModal = + (logsManuallyOpen || currentOperation !== null) && currentError === null; + + return ( + <> + <SyncingModal + isVisible={needSyncModal} + title={currentOperation ?? "Log output"} + subtext={statusText} + onClose={ + canClose ? () => (setLogsManuallyOpen(false)) : undefined + } + /> + <ErrorModal + isVisible={currentError !== null} + title={currentError?.title ?? ""} + description={currentError?.error} + onClose={() => (setCurrentError(null))} + ></ErrorModal> </> + ); } +export namespace OperationModals { + export let currentOperation: string | null; + export let currentError: OperationError | null; + export let statusText: string | null; + export let logsManuallyOpen: boolean; + + /** + * Creates a function that can be used to set whether or not a particular operation is currently in progress. + */ + export function useSetWorking(operationName: string): (working: boolean) => void { + return function setWorking(working) { + if (working) { + OperationModals.currentOperation = operationName; + } else { + OperationModals.statusText = null; + OperationModals.currentOperation = null; + } + }; + } + + /** + * Creates a function that can be used to set an error when a particular operation failed. + */ + export function useSetError(errorTitle: string): (error: unknown | null) => void { + return function setError(error) { + if (error === null) { + OperationModals.currentError = null; + } else { + Log.error(`${errorTitle}: ${String(error)}`); + OperationModals.currentError = { + title: errorTitle, + error: String(error), + }; + } + }; + } -function SyncingModal({ isVisible, title, subtext, onClose }: - { - isVisible: boolean, - title: string, - subtext: string | null, - onClose?: () => void }) { - if(isVisible) { - return <div className="modalBackground coverScreen"> - <div className="modal container screenWidth"> - <div className="syncingWindow"> - <div className="syncingTitle"> - <h2>{title}</h2> - {onClose === undefined && <ScaleLoader color={"white"} height={20} />} - <LogWindowControls onClose={onClose} /> - </div> - {subtext && <span className="syncingSubtext">{subtext}</span>} - - <LogWindow /> - </div> + export async function wrapOperation( + operationName: string, + errorModalTitle: string, + operation: () => Promise<void> + ) { + const setWorking = useSetWorking(operationName); + const setError = useSetError(errorModalTitle); + + setWorking(true); + try { + await operation(); + } catch (error) { + Log.error(errorModalTitle + ": " + error); + setError(error); + } finally { + setWorking(false); + } + } +} + +Object.defineProperties(OperationModals, { + currentOperation: { + get: () => state.currentOperation, + set: (value) => state.setCurrentOperation!(value), + enumerable: true, + configurable: false, + }, + currentError: { + get: () => state.currentError, + set: (value) => state.setCurrentError!(value), + enumerable: true, + configurable: false, + }, + statusText: { + get: () => state.statusText, + set: (value) => state.setStatusText!(value), + enumerable: true, + configurable: false, + }, + logsManuallyOpen: { + get: () => state.logsManuallyOpen, + set: (value) => state.setLogsManuallyOpen!(value), + enumerable: true, + configurable: false, + } +}) + +function SyncingModal({ + isVisible, + title, + subtext, + onClose, +}: { + isVisible: boolean; + title: string; + subtext: string | null; + onClose?: () => void; +}) { + if (isVisible) { + return ( + <div className="modalBackground coverScreen"> + <div className="modal container screenWidth"> + <div className="syncingWindow"> + <div className="syncingTitle"> + <h2>{title}</h2> + {onClose === undefined && ( + <ScaleLoader color={"white"} height={20} /> + )} + <LogWindowControls onClose={onClose} /> </div> + {subtext && <span className="syncingSubtext">{subtext}</span>} + + <LogWindow /> + </div> </div> - } else { - return <div className="modalBackground modalClosed coverScreen"></div> - } -} \ No newline at end of file + </div> + ); + } else { + return <div className="modalBackground modalClosed coverScreen"></div>; + } +} diff --git a/mbf-site/src/components/OptionsMenu.tsx b/mbf-site/src/components/OptionsMenu.tsx index cc700901..76a25231 100644 --- a/mbf-site/src/components/OptionsMenu.tsx +++ b/mbf-site/src/components/OptionsMenu.tsx @@ -8,12 +8,13 @@ import '../css/OptionsMenu.css' import { Collapsible } from './Collapsible'; import { ModStatus } from '../Messages'; import { AndroidManifest } from '../AndroidManifest'; -import { useSetError, wrapOperation } from '../SyncStore'; import { Log } from '../Logging'; import { Modal } from './Modal'; import { SplashScreenSelector } from './SplashScreenSelector'; -import { useDeviceStore } from '../DeviceStore'; import { gameId } from '../game_info'; +import { useDeviceConnectorContext } from '../hooks/DeviceConnector'; +import { OperationModals } from './OperationModals'; + interface OptionsMenuProps { setModStatus: (status: ModStatus) => void, @@ -50,7 +51,7 @@ function ModTools({ quit, modStatus, setModStatus }: { quit: () => void, modStatus: ModStatus, setModStatus: (status: ModStatus) => void}) { - const { device } = useDeviceStore((store) => ({ device: store.device })); + const { chosenDevice } = useDeviceConnectorContext(); return ( <div id="modTools"> @@ -58,11 +59,11 @@ function ModTools({ quit, modStatus, setModStatus }: { text="Kill Beat Saber" description="Immediately closes the game." onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - const setError = useSetError("Failed to kill Beat Saber process"); + const setError = OperationModals.useSetError("Failed to kill Beat Saber process"); try { - await device.subprocess.noneProtocol.spawnWait(`am force-stop ${gameId}`); + await chosenDevice.subprocess.noneProtocol.spawnWait(`am force-stop ${gameId}`); toast.success("Successfully killed Beat Saber"); } catch(e) { setError(e); @@ -73,11 +74,11 @@ function ModTools({ quit, modStatus, setModStatus }: { text="Restart Beat Saber" description="Immediately closes and restarts the game." onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - const setError = useSetError("Failed to kill Beat Saber process"); + const setError = OperationModals.useSetError("Failed to kill Beat Saber process"); try { - await device.subprocess.noneProtocol.spawnWait(`sh -c 'am force-stop ${gameId}; monkey -p com.beatgames.beatsaber -c android.intent.category.LAUNCHER 1'`); + await chosenDevice.subprocess.noneProtocol.spawnWait(`sh -c 'am force-stop ${gameId}; monkey -p com.beatgames.beatsaber -c android.intent.category.LAUNCHER 1'`); toast.success("Successfully restarted Beat Saber"); } catch (e) { setError(e); @@ -88,13 +89,13 @@ function ModTools({ quit, modStatus, setModStatus }: { text="Reinstall only core mods" description="Deletes all installed mods, then installs only the core mods." onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await wrapOperation( + await OperationModals.wrapOperation( "Reinstalling only core mods", "Failed to reinstall only core mods", async () => { - setModStatus(await quickFix(device, modStatus, true)); + setModStatus(await quickFix(chosenDevice, modStatus, true)); toast.success("All non-core mods removed!"); } ); @@ -104,11 +105,11 @@ function ModTools({ quit, modStatus, setModStatus }: { text="Uninstall Beat Saber" description="Uninstalls the game: this will remove all mods and quit MBF." onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - const setError = useSetError("Failed to uninstall Beat Saber"); + const setError = OperationModals.useSetError("Failed to uninstall Beat Saber"); try { - await uninstallBeatSaber(device); + await uninstallBeatSaber(chosenDevice); quit(); } catch (e) { setError(e); @@ -119,10 +120,10 @@ function ModTools({ quit, modStatus, setModStatus }: { text="Fix Player Data" description="Fixes an issue with player data permissions." onClick={async () => { - if (!device) return; - const setError = useSetError("Failed to fix player data"); + if (!chosenDevice) return; + const setError = OperationModals.useSetError("Failed to fix player data"); try { - if (await fixPlayerData(device)) { + if (await fixPlayerData(chosenDevice)) { toast.success("Successfully fixed player data issues"); } else { toast.error("No player data file found to fix"); @@ -141,10 +142,7 @@ function RepatchMenu({ modStatus, quit }: { quit: (err: unknown) => void } ) { - const { device, devicePreV51 } = useDeviceStore((store) => ({ - device: store.device, - devicePreV51: store.devicePreV51 - })); + const { chosenDevice, devicePreV51 } = useDeviceConnectorContext(); let manifest = useRef(new AndroidManifest(modStatus.app_info!.manifest_xml)); useEffect(() => { @@ -158,13 +156,13 @@ function RepatchMenu({ modStatus, quit }: { <PermissionsMenu manifest={manifest.current} /> <br/> <button onClick={async () => { - if (!device) return; + if (!chosenDevice) return; - await wrapOperation("Repatching Beat Saber", "Failed to repatch", async () => { + await OperationModals.wrapOperation("Repatching Beat Saber", "Failed to repatch", async () => { // TODO: Right now we do not set the mod status back to the DeviceModder state for it. // This is fine at the moment since repatching does not update this state in any important way, // but would be a problem if repatching did update it! - await patchApp(device, modStatus, null, manifest.current.toString(), true, false, false, splashScreen); + await patchApp(chosenDevice, modStatus, null, manifest.current.toString(), true, false, false, splashScreen); toast.success("Successfully repatched"); }) }}>Repatch game</button> @@ -214,10 +212,10 @@ function AdbLogger() { const [logging, setLogging] = useState(false); const [logFile, setLogFile] = useState(null as Blob | null); const [waitingForLog, setWaitingForLog] = useState(false); - const { device } = useDeviceStore((store)=> ({device: store.device})); + const { chosenDevice } = useDeviceConnectorContext(); useEffect(() => { - if(!logging || !device) { + if(!logging || !chosenDevice) { return () => {}; } @@ -225,7 +223,7 @@ function AdbLogger() { setWaitingForLog(false); setLogFile(null); let cancelled = false; - logcatToBlob(device, () => cancelled) + logcatToBlob(chosenDevice, () => cancelled) .then(log => { setLogFile(log); setWaitingForLog(false); diff --git a/mbf-site/src/definePropertiesFromSource.tsx b/mbf-site/src/definePropertiesFromSource.tsx new file mode 100644 index 00000000..63127c10 --- /dev/null +++ b/mbf-site/src/definePropertiesFromSource.tsx @@ -0,0 +1,30 @@ +/** + * Define properties on a target object that forward to the source object's properties. + */ +export function definePropertiesFromSource< + TSource extends object, + TTarget extends object, +>( + target: TTarget, + source: TSource, + keys?: Array<keyof TSource>, + readonly: boolean = false +) { + const propKeys = keys ?? (Object.keys(source) as Array<keyof TSource>); + for (const key of propKeys) { + Object.defineProperty(target, String(key), { + get() { + return (source as any)[key]; + }, + ...(readonly + ? {} + : { + set(value: any) { + (source as any)[key] = value; + }, + }), + enumerable: true, + configurable: false, + }); + } +} diff --git a/mbf-site/src/hooks/DeviceConnector.tsx b/mbf-site/src/hooks/DeviceConnector.tsx new file mode 100644 index 00000000..2006ac1c --- /dev/null +++ b/mbf-site/src/hooks/DeviceConnector.tsx @@ -0,0 +1,360 @@ +import { Adb, AdbDaemonTransport, AdbServerClient } from "@yume-chan/adb"; +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; +import { Log } from "../Logging"; +import { waitForDisconnect } from "../waitForDisconnect"; +import AdbWebCredentialStore from "@yume-chan/adb-credential-web"; +import { + AdbDaemonWebUsbDeviceManager, + AdbDaemonWebUsbConnection, +} from "@yume-chan/adb-daemon-webusb"; +import { installLoggers } from "../Agent"; + +const NON_LEGACY_ANDROID_VERSION: number = 11; + +/** + * Retrieves the Android version of the connected device. + * + * @param device - The ADB device instance to query for the Android version. + * @returns A promise that resolves to the Android version as a number. + * The version is extracted from the device's system property `ro.build.version.release`. + */ +async function getAndroidVersion(device: Adb) { + return Number( + await device.subprocess.noneProtocol.spawnWaitText( + "getprop ro.build.version.release" + ) + ); +} + +/** + * Connects to the ADB server using the given client and device. + * @param client The ADB server client to use for the connection. + * @param device The device to connect to. + * @returns + */ +async function connectAdbDevice( + client: AdbServerClient, + device: AdbServerClient.Device +): Promise<Adb> { + const transport = await client.createTransport(device); + return new Adb(transport); +} + +/** + * Attempts to stop the ADB server by sending a request to the ADB killer. + * @returns A promise that resolves when the ADB server is disconnected. + */ +async function tryDisconnectAdb() { + try { + await fetch("http://localhost:25898"); + } catch { + Log.warn("ADB killer is not running. ADB will have to be killed manually"); + } +} + +interface DeviceConnectorData { + /** Indicates if the connected device is running a pre-v51 (unsupported) OS version. */ + devicePreV51: boolean; + + /** Indicates if the device is currently in use by another process. */ + deviceInUse: boolean; + + /** Indicates if the device is currently authenticating. */ + authing: boolean; + + /** The currently selected ADB device, or null if none is selected. */ + chosenDevice: Adb | null; + + /** Indicates if a connection attempt is in progress. */ + connecting: boolean; + + /** Error message if the last connection attempt failed, or null if there was no error. */ + connectError: string | null; + + /** Indicates if the device is using a bridge connection. */ + usingBridge: boolean; +} + +interface DeviceConnectorCallbacks { + connectDevice: (device?: AdbServerClient.Device) => any; + disconnectDevice: () => void; + DeviceConnectorContextProvider: React.FC<PropsWithChildren>; +} + +const DeviceConnectorContext = + createContext<Readonly<DeviceConnectorData> | null>(null); + +type NoDeviceCause = "NoDeviceSelected" | "DeviceInUse"; + +export function useDeviceConnector( + serverClient: AdbServerClient | null +): Readonly<DeviceConnectorData & DeviceConnectorCallbacks> { + const [devicePreV51, setDevicePreV51] = useState<DeviceConnectorData["devicePreV51"]>(false); + const [deviceInUse, setDeviceInUse] = useState<DeviceConnectorData["deviceInUse"]>(false); + const [authing, setAuthing] = useState<DeviceConnectorData["authing"]>(false); + const [chosenDevice, setChosenDevice] = useState<DeviceConnectorData["chosenDevice"]>(null); + const [connecting, setConnecting] = useState<DeviceConnectorData["connecting"]>(false); + const [connectError, setConnectError] = useState<DeviceConnectorData["connectError"]>(null); + const [usingBridge, setUsingBridge] = useState<DeviceConnectorData["usingBridge"]>(false); + + const _DeviceConnectorContextProvider = useCallback<React.FC<PropsWithChildren>>( + function DeviceConnectorContextProvider({ children }) { + return ( + <DeviceConnectorContext.Provider + value={{ + devicePreV51, + deviceInUse, + authing, + chosenDevice, + connecting, + connectError, + usingBridge, + }} + > + {children} + </DeviceConnectorContext.Provider> + ); + }, + [devicePreV51, deviceInUse, authing, chosenDevice, connecting, connectError] + ); + + const clearDevice = useCallback(() => { + setChosenDevice(null); + setConnecting(false); + setAuthing(false); + setDevicePreV51(false); + setDeviceInUse(false); + setUsingBridge(false); + }, [setDevicePreV51, setAuthing, setChosenDevice, setConnecting]); + + /** + * Connects to the ADB server using WebUSB. + * @param setAuthing A function to call when the connection is being authenticated. + * @returns The connected ADB device or an error message. + */ + const connect = useCallback(async function connect(): 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); + setConnectError(String(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(true); + const transport: AdbDaemonTransport = + await AdbDaemonTransport.authenticate({ + serial: quest.serial, + connection, + credentialStore: keyStore, + }); + setAuthing(false); + + return new Adb(transport); + }, [setAuthing]); + + /** + * Connects to a Quest device using WebUSB and manages connection state. + * + * 1. Attempts to connect to a Quest device via WebUSB. + * 2. Handles device selection and device-in-use errors. + * 3. Updates authentication, device selection, and error state as appropriate. + * + * @param stateSetters - An object containing state setters. + */ + const connectWebUsb = useCallback(async function connectWebUsb(): Promise<Adb | null> { + try { + clearDevice(); + setConnecting(true); + + const result = await connect(); + + switch (result) { + case "NoDeviceSelected": { + break; + } + + case "DeviceInUse": { + clearDevice(); + setDeviceInUse(true); + break; + } + + default: { + clearDevice(); + setChosenDevice(result); + + return result; + } + } + } catch (error) { + Log.error("Failed to connect: " + error); + clearDevice(); + setConnectError(String(error)); + } + + return null; + }, [ + connect, + setConnecting, + setAuthing, + setDeviceInUse, + setChosenDevice, + setConnectError, + ]); + + /** + * Handles the process of connecting to an ADB device and managing its connection state. + * + * 1. Retrieves the Android version of the device. + * 2. Sets a flag if the device is running an unsupported (pre-v51) OS version. + * 3. Updates authentication and device selection state. + * 4. Waits for the device to disconnect. + * 5. Resets the selected device state after disconnection. + * + * @param device - The connected ADB device instance. + * @param stateSetters - An object containing state setters. + */ + const initializeDevice = useCallback( + async function initializeDevice(device: Adb) { + const androidVersion = await getAndroidVersion(device); + Log.debug("Device android version: " + androidVersion); + setDevicePreV51(androidVersion < NON_LEGACY_ANDROID_VERSION); + setAuthing(false); + setChosenDevice(device); + + await waitForDisconnect(device); + }, + [setDevicePreV51, setAuthing, setChosenDevice, setConnecting, clearDevice] + ); + + /** + * Connects to a device using the ADB bridge client and manages connection state. + * + * 1. Attempts to create an ADB connection to the specified device using the provided bridge client. + * 2. If successful, calls `connectDevice` to handle version checks and state updates. + * 3. Handles errors by logging, setting the connection error state, and resetting the selected device. + * + * @param serverClient - The ADB server client used for the bridge connection. + * @param device - The target device to connect to. + * @param stateSetters - An object containing state setters. + */ + const connectBridgeDevice = useCallback( + async function connectBridgeDevice(device: AdbServerClient.Device) { + try { + if (serverClient === null) { + Log.error("Bridge client is null, cannot connect to device"); + return; + } + + setConnecting(true); + + const adbDevice = await connectAdbDevice(serverClient, device); + await initializeDevice(adbDevice); + } catch (error) { + Log.error("Failed to connect: " + error); + setConnectError(String(error)); + } finally { + clearDevice(); + } + }, + [ + serverClient, + connectAdbDevice, + setConnectError, + setChosenDevice, + setConnecting, + ] + ); + + const connectDevice = useCallback( + async function connectDevice(device?: AdbServerClient.Device) { + if (chosenDevice) { + throw new Error("Device is already connected"); + } + + try { + if (device) { + connectBridgeDevice(device); + } else { + const device = await connectWebUsb(); + + if (device) { + await initializeDevice(device); + } + } + } catch (err) { + Log.error(String(err)); + } finally { + clearDevice(); + } + }, + [initializeDevice, connectBridgeDevice] + ); + + const disconnectDevice = useCallback(() => { + try { + chosenDevice?.close(); + } catch (err) { + Log.error("Failed to disconnect device: " + err); + } finally { + clearDevice(); + setConnectError(null); + } + }, []); + + return { + devicePreV51, + deviceInUse, + authing, + chosenDevice, + connecting, + connectError, + usingBridge, + connectDevice, + disconnectDevice, + DeviceConnectorContextProvider: _DeviceConnectorContextProvider, + }; +} + +export function useDeviceConnectorContext() { + const context = useContext(DeviceConnectorContext); + + if (!context) { + throw new Error( + "useDeviceConnectorContext must be used within a DeviceConnectorContextProvider" + ); + } + + return context; +} \ No newline at end of file diff --git a/mbf-site/src/waitForDisconnect.ts b/mbf-site/src/waitForDisconnect.ts new file mode 100644 index 00000000..33bc84a1 --- /dev/null +++ b/mbf-site/src/waitForDisconnect.ts @@ -0,0 +1,39 @@ +import type { Adb } from "@yume-chan/adb"; +import { PromiseResolver } from "@yume-chan/async"; +import { Log } from "./Logging"; + +const disconnectPromises = new Map<string, Promise<void>>(); +export async function waitForDisconnect(device: Adb) { + if (disconnectPromises.has(device.serial)) { + Log.debug(`Already waiting for ${device.serial} to disconnect`); + return await disconnectPromises.get(device.serial); + } + + Log.debug(`Waiting for ${device.serial} to disconnect`); + var resolver = new PromiseResolver<void>(); + + disconnectPromises.set(device.serial, resolver.promise); + + // Track if the transport disconnects early + let disconnectedEarly = true; + setTimeout(() => disconnectedEarly = false, 1000); + + // Wait for the transport to determine disconnect + await device.transport.disconnected; + + // Old adb server versions don't support the wait-for-any-disconnect feautre + // so if the transport disconnects within 1 second, we spawn a process that + // never exits and await it instead. + if (disconnectedEarly) { + try { + Log.debug(`Waiting for ${device.serial} to disconnect using subprocess`); + await device.subprocess.noneProtocol.spawnWait("read"); + } catch (error) { + console.error("ADB server process exited: " + error, error); + } + } + + Log.debug(`Devoce ${device.serial} disconnected`); + resolver.resolve(); + disconnectPromises.delete(device.serial); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95d61cb4..6b7f936f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: semver: specifier: ^7.6.0 version: 7.7.2 + valtio: + specifier: ^2.1.8 + version: 2.1.8(@types/react@18.3.23)(react@18.3.1) vite: specifier: ^5.2.8 version: 5.4.19(@types/node@24.2.0)(terser@5.43.1) @@ -2440,6 +2443,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2781,6 +2787,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + valtio@2.1.8: + resolution: {integrity: sha512-fjTPbJyKEmfVBZUOh3V0OtMHoFUGr4+4XpejjxhNJE/IS2l8rDbyJuzi3w/fZWBDyk7BJOpG+lmvTK5iiVhXuQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + vite-plugin-mkcert@1.17.8: resolution: {integrity: sha512-S+4tNEyGqdZQ3RLAG54ETeO2qyURHWrVjUWKYikLAbmhh/iJ+36gDEja4OWwFyXNuvyXcZwNt5TZZR9itPeG5Q==} engines: {node: '>=v16.7.0'} @@ -4679,6 +4697,8 @@ snapshots: process@0.11.10: {} + proxy-compare@3.0.1: {} + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -5063,6 +5083,13 @@ snapshots: dependencies: react: 18.3.1 + valtio@2.1.8(@types/react@18.3.23)(react@18.3.1): + dependencies: + proxy-compare: 3.0.1 + optionalDependencies: + '@types/react': 18.3.23 + react: 18.3.1 + vite-plugin-mkcert@1.17.8(vite@5.4.19(@types/node@24.2.0)(terser@5.43.1)): dependencies: axios: 1.11.0(debug@4.4.1)