From bb8889eb4ce74f48bc33d00a0fc0cbff66246f48 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 2 Oct 2025 02:18:02 -0500 Subject: [PATCH 1/4] Re-factor agent requests to use streams to capture and process output. - Added createLoggingWritableStream function to log messages from the agent. - Implemented WritableStream to capture and process agent output. - Improved error handling for JSON parsing of agent messages. - Updated sendRequest function to utilize the new logging stream. --- mbf-site/src/Agent.ts | 135 ++++++++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 39 deletions(-) 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; } } From b12b4b29ec786ce3d97de4f9d92465254a274393 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 2 Oct 2025 02:18:11 -0500 Subject: [PATCH 2/4] Refactor device and connection management - Removed DeviceStore and SyncStore, consolidating their functionality into a new context-based approach. - Introduce DeviceConnector and BridgeManager hooks to manage device connections and bridge interactions. - Updated ModManager, OptionsMenu, and other components to utilize the new context for device management. - Refactor operation modals to use a new context-based approach. --- mbf-site/src/App.tsx | 167 +++------ mbf-site/src/DeviceModder.tsx | 67 ++-- mbf-site/src/DeviceStore.ts | 34 -- mbf-site/src/SyncStore.ts | 95 ------ mbf-site/src/components/ModManager.tsx | 47 +-- mbf-site/src/components/OpenLogsButton.tsx | 6 +- mbf-site/src/components/OperationModals.tsx | 338 +++++++++++++++--- mbf-site/src/components/OptionsMenu.tsx | 53 ++- mbf-site/src/hooks/DeviceConnector.tsx | 360 ++++++++++++++++++++ mbf-site/src/waitForDisconnect.ts | 39 +++ 10 files changed, 816 insertions(+), 390 deletions(-) delete mode 100644 mbf-site/src/DeviceStore.ts delete mode 100644 mbf-site/src/SyncStore.ts create mode 100644 mbf-site/src/hooks/DeviceConnector.tsx create mode 100644 mbf-site/src/waitForDisconnect.ts diff --git a/mbf-site/src/App.tsx b/mbf-site/src/App.tsx index 1b8e9508..2cd47694 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 { useOperationModals } from './components/OperationModals'; import { OpenLogsButton } from './components/OpenLogsButton'; import { isViewingOnIos, isViewingOnMobile, isViewingOnWindows, usingOculusBrowser } from './platformDetection'; import { SourceUrl } from '.'; -import { useDeviceStore } from './DeviceStore'; +import { useDeviceConnector } from './hooks/DeviceConnector'; -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); -} - -// 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> + </> } } @@ -268,16 +175,20 @@ function AppContents() { } function App() { + const { OperationModalContextProvider, OperationModals } = useOperationModals(); + return <div className='main'> - <AppContents /> - <CornerMenu /> - <OperationModals /> - <ToastContainer - position="bottom-right" - theme="dark" - autoClose={5000} - transition={Bounce} - hideProgressBar={true} /> + <OperationModalContextProvider> + <AppContents /> + <CornerMenu /> + <OperationModals /> + <ToastContainer + position="bottom-right" + theme="dark" + autoClose={5000} + transition={Bounce} + hideProgressBar={true} /> + </OperationModalContextProvider> </div> } diff --git a/mbf-site/src/DeviceModder.tsx b/mbf-site/src/DeviceModder.tsx index d6a2b937..17acfb1f 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 { useOperationModalsContext } 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'> @@ -215,11 +213,12 @@ interface InstallStatusProps { } function InstallStatus(props: InstallStatusProps) { + const modals = useOperationModalsContext(); const { modStatus, onFixed } = props; 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 +237,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))); + modals.wrapOperation("Fixing issues", "Failed to fix install", async () => + onFixed(await quickFix(chosenDevice, modStatus, false))); }}>Fix issues</button> </div> } @@ -250,7 +249,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 +266,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 +297,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 +306,12 @@ function PatchingMenu(props: PatchingMenuProps) { manifest?.applyPatchingManifestMod(); 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 +362,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 +472,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 +494,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 +523,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 +532,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 +543,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 +552,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..98dd9653 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 { useOperationModalsContext } from './OperationModals'; interface ModManagerProps { @@ -99,21 +99,22 @@ interface ModMenuProps { } function InstalledModsMenu(props: ModMenuProps) { + const modals = useOperationModalsContext(); const { mods, 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 modals.wrapOperation("Syncing mods", "Failed to sync mods", async () => { + const modSyncResult = await setModStatuses(chosenDevice, changes); setMods(modSyncResult.installed_mods); if(modSyncResult.failures !== null) { @@ -133,10 +134,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 modals.wrapOperation("Removing mod", "Failed to remove mod", async () => { + setMods(await removeMod(chosenDevice, mod.id)); const newChanges = { ...changes }; delete newChanges[mod.id]; @@ -213,12 +214,13 @@ function AddModsMenu(props: ModMenuProps) { setMods, gameVersion } = props; - const { device } = useDeviceStore((state) => ({ device: state.device })); + const { chosenDevice } = useDeviceConnectorContext(); + const modals = useOperationModalsContext(); // 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 +233,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 +267,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 +278,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 +292,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 +306,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 = modals.useSetWorking("Importing"); setWorking(true); while(importQueue.length > 0 && !disconnected) { @@ -315,17 +316,17 @@ function AddModsMenu(props: ModMenuProps) { if(newImport.type == "File") { const file = (newImport as QueuedFileImport).file; - setStatusText(`Processing file ${file.name}`); + modals.setStatusText(`Processing file ${file.name}`); await handleFileImport(file); } else if(newImport.type == "Url") { const url = (newImport as QueuedUrlImport).url; - setStatusText(`Processing url ${url}`); + modals.setStatusText(`Processing url ${url}`); await handleUrlImport(url); } else if(newImport.type == "ModRepo") { const mod = (newImport as QueuedModRepoImport).mod; - setStatusText(`Installing ${mod.name} v${mod.version}`); + modals.setStatusText(`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..23f6bf81 100644 --- a/mbf-site/src/components/OpenLogsButton.tsx +++ b/mbf-site/src/components/OpenLogsButton.tsx @@ -1,14 +1,14 @@ import '../css/OpenLogs.css'; import LogsIcon from '../icons/logs.svg'; -import { useSyncStore } from '../SyncStore'; import { LabelledIconButton } from './LabelledIconButton'; +import { useOperationModalsContext } from './OperationModals'; export function OpenLogsButton() { - const { setLogsManuallyOpen } = useSyncStore(); + const modals = useOperationModalsContext(); return <div className="openLogs"> <LabelledIconButton iconSrc={LogsIcon} iconAlt="Piece of paper with lines of text" label="Logs" - onClick={() => setLogsManuallyOpen(true)}/> + onClick={() => modals.setLogsManuallyOpen(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..33c6c4fc 100644 --- a/mbf-site/src/components/OperationModals.tsx +++ b/mbf-site/src/components/OperationModals.tsx @@ -1,57 +1,303 @@ +/** + * @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, { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { Log } from "../Logging"; + +/** + * An error that occured within a particular operation. + */ +export interface OperationError { + title: string; + error: string; +} + +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; + 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>>; + + /** Creates a function that can be used to set whether or not a particular operation is currently in progress. */ + useSetWorking: (operationName: string) => (working: boolean) => void; + + /** Creates a function that can be used to set an error when a particular operation failed. */ + useSetError: (errorTitle: string) => (error: unknown | null) => void; + + /** Used to wrap a particular operation while displaying the logging window and any errors if appropriate. */ + wrapOperation: ( + operationName: string, + errorModalTitle: string, + operation: () => Promise<void> + ) => Promise<void>; +} + +interface OperationModalsComponents { + OperationModalContextProvider: React.FC<React.PropsWithChildren>; + OperationModals: React.FC; +} + +const OperationModalsContext = + createContext<Readonly<OperationModalsData> | null>(null); + + /** + * Provides the components and context necessary to track ongoing operations. + * + * Do not destructure the returned object, as it will be updated as necessary. + * @returns + */ +export function useOperationModals(): OperationModalsComponents { + const _OperationModalContextProvider = useCallback< + React.FC<React.PropsWithChildren> + >(function OperationModalContextProvider({ children }) { + const context = useContext(OperationModalsContext); + if (context !== null) { + throw new Error("OperationModalsContextProvider cannot be nested."); + } + + return ( + <OperationModalsContext.Provider + value={{ + currentOperation: null, + currentError: null, + statusText: null, + logsManuallyOpen: false, + setCurrentOperation: () => undefined, + setCurrentError: () => undefined, + setStatusText: () => undefined, + setLogsManuallyOpen: () => undefined, + useSetWorking: () => () => {}, + useSetError: () => () => {}, + wrapOperation: async () => Promise.resolve(), + }} + > + {children} + </OperationModalsContext.Provider> + ); + }, []); + + return { + OperationModalContextProvider: _OperationModalContextProvider, + OperationModals, + }; +} + +export const useOperationModalsContext = () => { + const context = useContext(OperationModalsContext); + + if (context === null) { + throw new Error( + "useOperationModalsContext must be used within an OperationModalContextProvider." + ); + } + + return context; +}; // 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> +function OperationModals() { + const [currentOperation, setCurrentOperation] = useState<string | null>(null); + const [currentError, setCurrentError] = useState<OperationError | null>(null); + const [statusText, setStatusText] = useState<string | null>(null); + const [logsManuallyOpen, setLogsManuallyOpen] = useState(false); + const context = useContext(OperationModalsContext)! as OperationModalsData & { + modalRenderer: boolean; + }; + + if (context === null) { + throw new Error( + "OperationModals must be used within an OperationModalContextProvider." + ); + } + + /** + * Creates a function that can be used to set whether or not a particular operation is currently in progress. + */ + const _useSetWorking = useCallback( + function useSetWorking(operationName: string): (working: boolean) => void { + return function setWorking(working) { + if (working) { + setCurrentOperation(operationName); + } else { + setStatusText(null); + setCurrentOperation(null); + } + }; + }, + [setCurrentOperation, setStatusText] + ); + + /** + * Creates a function that can be used to set an error when a particular operation failed. + */ + const _useSetError = useCallback( + function useSetError(errorTitle: string): (error: unknown | null) => void { + return function setError(error) { + if (error === null) { + setCurrentError(null); + } else { + Log.error(`${errorTitle}: ${String(error)}`); + setCurrentError({ + title: errorTitle, + error: String(error), + }); + } + }; + }, + [setCurrentError] + ); + + const _wrapOperation = useCallback( + 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); + } + }, + [_useSetWorking, _useSetError] + ); + + useEffect(() => { + context.currentOperation = currentOperation; + }, [context, currentOperation]); + + useEffect(() => { + context.currentError = currentError; + }, [context, currentError]); + + useEffect(() => { + context.statusText = statusText; + }, [context, statusText]); + + useEffect(() => { + context.logsManuallyOpen = logsManuallyOpen; + }, [context, logsManuallyOpen]); + + useEffect(() => { + context.setCurrentOperation = setCurrentOperation; + }, [context, setCurrentOperation]); + + useEffect(() => { + context.setCurrentError = setCurrentError; + }, [context, setCurrentError]); + + useEffect(() => { + context.setStatusText = setStatusText; + }, [context, setStatusText]); + + useEffect(() => { + context.setLogsManuallyOpen = setLogsManuallyOpen; + }, [context, setLogsManuallyOpen]); + + useEffect(() => { + context.useSetWorking = _useSetWorking; + }, [context, _useSetWorking]); + + useEffect(() => { + context.useSetError = _useSetError; + }, [context, _useSetError]); + + useEffect(() => { + context.wrapOperation = _wrapOperation; + }, [context, _wrapOperation]); + + useEffect(() => { + context.modalRenderer = true; + + return () => { + context.modalRenderer = false; + }; + }, [context]); + + 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> </> + ); } - -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> +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> - } + </div> + ); + } else { + return <div className="modalBackground modalClosed coverScreen"></div>; + } } \ No newline at end of file diff --git a/mbf-site/src/components/OptionsMenu.tsx b/mbf-site/src/components/OptionsMenu.tsx index b4d8c997..8d1cfeb3 100644 --- a/mbf-site/src/components/OptionsMenu.tsx +++ b/mbf-site/src/components/OptionsMenu.tsx @@ -8,12 +8,12 @@ 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 { useOperationModalsContext } from './OperationModals'; interface OptionsMenuProps { setModStatus: (status: ModStatus) => void, @@ -50,7 +50,8 @@ function ModTools({ quit, modStatus, setModStatus }: { quit: () => void, modStatus: ModStatus, setModStatus: (status: ModStatus) => void}) { - const { device } = useDeviceStore((store) => ({ device: store.device })); + const { chosenDevice } = useDeviceConnectorContext(); + const modals = useOperationModalsContext(); 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 = modals.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 = modals.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 modals.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 = modals.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 = modals.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,8 @@ function RepatchMenu({ modStatus, quit }: { quit: (err: unknown) => void } ) { - const { device, devicePreV51 } = useDeviceStore((store) => ({ - device: store.device, - devicePreV51: store.devicePreV51 - })); + const { chosenDevice, devicePreV51 } = useDeviceConnectorContext(); + const { wrapOperation } = useOperationModalsContext(); let manifest = useRef(new AndroidManifest(modStatus.app_info!.manifest_xml)); useEffect(() => { @@ -158,13 +157,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 () => { // 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 +213,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 +224,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/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 From c2c70e7b9facf40aa7fc2c313625a23a80b22bb5 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:27:20 -0500 Subject: [PATCH 3/4] feat: update dependencies and refactor operation modals - Added `valtio` dependency for state management. - Updated `@types/w3c-web-usb` to version `^1.0.10`. - Refactored operation modals to use `valtio` for state management. - Simplified modal context usage across components. - Improved error handling and operation wrapping in modals. - Defined properties from source for better state management. --- mbf-site/package.json | 3 +- mbf-site/src/App.tsx | 26 +- mbf-site/src/DeviceModder.tsx | 5 +- mbf-site/src/components/ModManager.tsx | 16 +- mbf-site/src/components/OpenLogsButton.tsx | 5 +- mbf-site/src/components/OperationModals.tsx | 284 ++++++-------------- mbf-site/src/components/OptionsMenu.tsx | 17 +- mbf-site/src/definePropertiesFromSource.tsx | 30 +++ pnpm-lock.yaml | 27 ++ 9 files changed, 172 insertions(+), 241 deletions(-) create mode 100644 mbf-site/src/definePropertiesFromSource.tsx diff --git a/mbf-site/package.json b/mbf-site/package.json index b6b42597..91930f03 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:^", @@ -25,6 +25,7 @@ "react-spinners": "^0.14.1", "react-toastify": "^10.0.5", "semver": "^7.6.0", + "valtio": "^2.1.8", "vite": "^5.2.8", "vite-plugin-mkcert": "^1.17.5", "vite-tsconfig-paths": "^4.3.2", diff --git a/mbf-site/src/App.tsx b/mbf-site/src/App.tsx index 2cd47694..2defb81b 100644 --- a/mbf-site/src/App.tsx +++ b/mbf-site/src/App.tsx @@ -13,11 +13,11 @@ import 'react-toastify/dist/ReactToastify.css'; import { CornerMenu } from './components/CornerMenu'; import { installLoggers, setCoreModOverrideUrl } from './Agent'; import { Log } from './Logging'; -import { useOperationModals } from './components/OperationModals'; import { OpenLogsButton } from './components/OpenLogsButton'; import { isViewingOnIos, isViewingOnMobile, isViewingOnWindows, usingOculusBrowser } from './platformDetection'; import { SourceUrl } from '.'; import { useDeviceConnector } from './hooks/DeviceConnector'; +import { OperationModals } from './components/OperationModals'; function ChooseDevice() { @@ -174,21 +174,17 @@ function AppContents() { } } -function App() { - const { OperationModalContextProvider, OperationModals } = useOperationModals(); - +function App() { return <div className='main'> - <OperationModalContextProvider> - <AppContents /> - <CornerMenu /> - <OperationModals /> - <ToastContainer - position="bottom-right" - theme="dark" - autoClose={5000} - transition={Bounce} - hideProgressBar={true} /> - </OperationModalContextProvider> + <AppContents /> + <CornerMenu /> + <OperationModals /> + <ToastContainer + position="bottom-right" + theme="dark" + autoClose={5000} + transition={Bounce} + hideProgressBar={true} /> </div> } diff --git a/mbf-site/src/DeviceModder.tsx b/mbf-site/src/DeviceModder.tsx index 17acfb1f..5c1a0d75 100644 --- a/mbf-site/src/DeviceModder.tsx +++ b/mbf-site/src/DeviceModder.tsx @@ -15,7 +15,7 @@ import { OpenLogsButton } from './components/OpenLogsButton'; import { lte as semverLte } from 'semver'; import { gameId } from './game_info'; import { useDeviceConnectorContext } from './hooks/DeviceConnector'; -import { useOperationModalsContext } from './components/OperationModals'; +import { OperationModals } from './components/OperationModals'; interface DeviceModderProps { // Quits back to the main menu, optionally giving an error that caused the quit. @@ -213,7 +213,6 @@ interface InstallStatusProps { } function InstallStatus(props: InstallStatusProps) { - const modals = useOperationModalsContext(); const { modStatus, onFixed } = props; const modloaderStatus = modStatus.modloader_install_status; @@ -239,7 +238,7 @@ function InstallStatus(props: InstallStatusProps) { <button onClick={async () => { if (!chosenDevice) return; - modals.wrapOperation("Fixing issues", "Failed to fix install", async () => + OperationModals.wrapOperation("Fixing issues", "Failed to fix install", async () => onFixed(await quickFix(chosenDevice, modStatus, false))); }}>Fix issues</button> </div> diff --git a/mbf-site/src/components/ModManager.tsx b/mbf-site/src/components/ModManager.tsx index 98dd9653..8ec8043a 100644 --- a/mbf-site/src/components/ModManager.tsx +++ b/mbf-site/src/components/ModManager.tsx @@ -16,7 +16,7 @@ import { Log } from "../Logging"; import { ModRepoMod } from "../ModsRepo"; import SyncIcon from "../icons/sync.svg" import { useDeviceConnectorContext } from '../hooks/DeviceConnector'; -import { useOperationModalsContext } from './OperationModals'; +import { OperationModals } from "./OperationModals"; interface ModManagerProps { @@ -99,7 +99,6 @@ interface ModMenuProps { } function InstalledModsMenu(props: ModMenuProps) { - const modals = useOperationModalsContext(); const { mods, setMods, gameVersion @@ -113,7 +112,7 @@ function InstalledModsMenu(props: ModMenuProps) { if (!chosenDevice) return; setChanges({}); Log.debug("Installing mods, statuses requested: " + JSON.stringify(changes)); - await modals.wrapOperation("Syncing mods", "Failed to sync mods", async () => { + await OperationModals.wrapOperation("Syncing mods", "Failed to sync mods", async () => { const modSyncResult = await setModStatuses(chosenDevice, changes); setMods(modSyncResult.installed_mods); @@ -136,7 +135,7 @@ function InstalledModsMenu(props: ModMenuProps) { onRemoved={async () => { if (!chosenDevice) return; - await modals.wrapOperation("Removing mod", "Failed to remove mod", async () => { + await OperationModals.wrapOperation("Removing mod", "Failed to remove mod", async () => { setMods(await removeMod(chosenDevice, mod.id)); const newChanges = { ...changes }; @@ -215,7 +214,6 @@ function AddModsMenu(props: ModMenuProps) { gameVersion } = props; const { chosenDevice } = useDeviceConnectorContext(); - const modals = useOperationModalsContext(); // 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. @@ -307,7 +305,7 @@ function AddModsMenu(props: ModMenuProps) { let disconnected = false; chosenDevice.disconnected.then(() => disconnected = true); - const setWorking = modals.useSetWorking("Importing"); + const setWorking = OperationModals.useSetWorking("Importing"); setWorking(true); while(importQueue.length > 0 && !disconnected) { @@ -316,17 +314,17 @@ function AddModsMenu(props: ModMenuProps) { if(newImport.type == "File") { const file = (newImport as QueuedFileImport).file; - modals.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; - modals.setStatusText(`Processing url ${url}`); + OperationModals.statusText = `Processing url ${url}`; await handleUrlImport(url); } else if(newImport.type == "ModRepo") { const mod = (newImport as QueuedModRepoImport).mod; - modals.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 23f6bf81..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 { LabelledIconButton } from './LabelledIconButton'; -import { useOperationModalsContext } from './OperationModals'; +import { OperationModals } from './OperationModals'; export function OpenLogsButton() { - const modals = useOperationModalsContext(); return <div className="openLogs"> <LabelledIconButton iconSrc={LogsIcon} iconAlt="Piece of paper with lines of text" label="Logs" - onClick={() => modals.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 33c6c4fc..4bd02d65 100644 --- a/mbf-site/src/components/OperationModals.tsx +++ b/mbf-site/src/components/OperationModals.tsx @@ -9,15 +9,10 @@ import { ScaleLoader } from "react-spinners"; import { LogWindow, LogWindowControls } from "./LogWindow"; import { ErrorModal } from "./Modal"; -import React, { - createContext, - PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from "react"; +import React from "react"; +import { proxy, useSnapshot } from "valtio"; import { Log } from "../Logging"; +import { definePropertiesFromSource } from "../definePropertiesFromSource"; /** * An error that occured within a particular operation. @@ -27,6 +22,13 @@ export interface OperationError { error: string; } +const modalState = proxy<OperationModalsData>({ + currentOperation: null, + currentError: null, + statusText: null, + logsManuallyOpen: false, +}); + interface OperationModalsData { /** Name of the current ongoing operation */ currentOperation: string | null; @@ -37,213 +39,76 @@ interface OperationModalsData { /** Whether or not the logs have been manually opened by the user. */ logsManuallyOpen: 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>>; +} +interface OperationModalsActions { /** Creates a function that can be used to set whether or not a particular operation is currently in progress. */ - useSetWorking: (operationName: string) => (working: boolean) => void; + useSetWorking: typeof useSetWorking; /** Creates a function that can be used to set an error when a particular operation failed. */ - useSetError: (errorTitle: string) => (error: unknown | null) => void; + useSetError: typeof useSetError; /** Used to wrap a particular operation while displaying the logging window and any errors if appropriate. */ - wrapOperation: ( - operationName: string, - errorModalTitle: string, - operation: () => Promise<void> - ) => Promise<void>; + wrapOperation: typeof wrapOperation; } -interface OperationModalsComponents { - OperationModalContextProvider: React.FC<React.PropsWithChildren>; - OperationModals: React.FC; +/** + * Creates a function that can be used to set whether or not a particular operation is currently in progress. + */ +function useSetWorking( + operationName: string +): (working: boolean) => void { + return function setWorking(working) { + if (working) { + modalState.currentOperation = operationName; + } else { + modalState.statusText = null; + modalState.currentOperation = null; + } + }; } -const OperationModalsContext = - createContext<Readonly<OperationModalsData> | null>(null); - - /** - * Provides the components and context necessary to track ongoing operations. - * - * Do not destructure the returned object, as it will be updated as necessary. - * @returns - */ -export function useOperationModals(): OperationModalsComponents { - const _OperationModalContextProvider = useCallback< - React.FC<React.PropsWithChildren> - >(function OperationModalContextProvider({ children }) { - const context = useContext(OperationModalsContext); - if (context !== null) { - throw new Error("OperationModalsContextProvider cannot be nested."); +/** + * Creates a function that can be used to set an error when a particular operation failed. + */ +function useSetError( + errorTitle: string +): (error: unknown | null) => void { + return function setError(error) { + if (error === null) { + modalState.currentError = null; + } else { + Log.error(`${errorTitle}: ${String(error)}`); + modalState.currentError = { + title: errorTitle, + error: String(error), + }; } - - return ( - <OperationModalsContext.Provider - value={{ - currentOperation: null, - currentError: null, - statusText: null, - logsManuallyOpen: false, - setCurrentOperation: () => undefined, - setCurrentError: () => undefined, - setStatusText: () => undefined, - setLogsManuallyOpen: () => undefined, - useSetWorking: () => () => {}, - useSetError: () => () => {}, - wrapOperation: async () => Promise.resolve(), - }} - > - {children} - </OperationModalsContext.Provider> - ); - }, []); - - return { - OperationModalContextProvider: _OperationModalContextProvider, - OperationModals, }; } -export const useOperationModalsContext = () => { - const context = useContext(OperationModalsContext); - - if (context === null) { - throw new Error( - "useOperationModalsContext must be used within an OperationModalContextProvider." - ); +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); } - - return context; -}; +} // Component that displays the log window when an operation is in progress, and displays errors when the operation failed. -function OperationModals() { - const [currentOperation, setCurrentOperation] = useState<string | null>(null); - const [currentError, setCurrentError] = useState<OperationError | null>(null); - const [statusText, setStatusText] = useState<string | null>(null); - const [logsManuallyOpen, setLogsManuallyOpen] = useState(false); - const context = useContext(OperationModalsContext)! as OperationModalsData & { - modalRenderer: boolean; - }; - - if (context === null) { - throw new Error( - "OperationModals must be used within an OperationModalContextProvider." - ); - } - - /** - * Creates a function that can be used to set whether or not a particular operation is currently in progress. - */ - const _useSetWorking = useCallback( - function useSetWorking(operationName: string): (working: boolean) => void { - return function setWorking(working) { - if (working) { - setCurrentOperation(operationName); - } else { - setStatusText(null); - setCurrentOperation(null); - } - }; - }, - [setCurrentOperation, setStatusText] - ); - - /** - * Creates a function that can be used to set an error when a particular operation failed. - */ - const _useSetError = useCallback( - function useSetError(errorTitle: string): (error: unknown | null) => void { - return function setError(error) { - if (error === null) { - setCurrentError(null); - } else { - Log.error(`${errorTitle}: ${String(error)}`); - setCurrentError({ - title: errorTitle, - error: String(error), - }); - } - }; - }, - [setCurrentError] - ); - - const _wrapOperation = useCallback( - 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); - } - }, - [_useSetWorking, _useSetError] - ); - - useEffect(() => { - context.currentOperation = currentOperation; - }, [context, currentOperation]); - - useEffect(() => { - context.currentError = currentError; - }, [context, currentError]); - - useEffect(() => { - context.statusText = statusText; - }, [context, statusText]); - - useEffect(() => { - context.logsManuallyOpen = logsManuallyOpen; - }, [context, logsManuallyOpen]); - - useEffect(() => { - context.setCurrentOperation = setCurrentOperation; - }, [context, setCurrentOperation]); - - useEffect(() => { - context.setCurrentError = setCurrentError; - }, [context, setCurrentError]); - - useEffect(() => { - context.setStatusText = setStatusText; - }, [context, setStatusText]); - - useEffect(() => { - context.setLogsManuallyOpen = setLogsManuallyOpen; - }, [context, setLogsManuallyOpen]); - - useEffect(() => { - context.useSetWorking = _useSetWorking; - }, [context, _useSetWorking]); - - useEffect(() => { - context.useSetError = _useSetError; - }, [context, _useSetError]); - - useEffect(() => { - context.wrapOperation = _wrapOperation; - }, [context, _wrapOperation]); - - useEffect(() => { - context.modalRenderer = true; - - return () => { - context.modalRenderer = false; - }; - }, [context]); +export function OperationModals() { + const { currentOperation, currentError, statusText, logsManuallyOpen } = useSnapshot(modalState); const canClose = logsManuallyOpen && currentError === null; const needSyncModal = @@ -255,18 +120,35 @@ function OperationModals() { isVisible={needSyncModal} title={currentOperation ?? "Log output"} subtext={statusText} - onClose={canClose ? () => setLogsManuallyOpen(false) : undefined} + onClose={ + canClose + ? () => (modalState.logsManuallyOpen = false) + : undefined + } /> <ErrorModal isVisible={currentError !== null} title={currentError?.title ?? ""} description={currentError?.error} - onClose={() => setCurrentError(null)} + onClose={() => (modalState.currentError = null)} ></ErrorModal> </> ); } +export namespace OperationModals { + export let currentOperation: string | null; + export let currentError: OperationError | null; + export let statusText: string | null; + export let logsManuallyOpen: boolean; + export let useSetWorking: OperationModalsActions["useSetWorking"]; + export let useSetError: OperationModalsActions["useSetError"]; + export let wrapOperation: OperationModalsActions["wrapOperation"]; +} + +definePropertiesFromSource(OperationModals, modalState); +definePropertiesFromSource(OperationModals, {useSetWorking, useSetError, wrapOperation}, undefined, true); + function SyncingModal({ isVisible, title, @@ -300,4 +182,4 @@ function SyncingModal({ } else { return <div className="modalBackground modalClosed coverScreen"></div>; } -} \ No newline at end of file +} diff --git a/mbf-site/src/components/OptionsMenu.tsx b/mbf-site/src/components/OptionsMenu.tsx index 8d1cfeb3..df71f95d 100644 --- a/mbf-site/src/components/OptionsMenu.tsx +++ b/mbf-site/src/components/OptionsMenu.tsx @@ -13,7 +13,8 @@ import { Modal } from './Modal'; import { SplashScreenSelector } from './SplashScreenSelector'; import { gameId } from '../game_info'; import { useDeviceConnectorContext } from '../hooks/DeviceConnector'; -import { useOperationModalsContext } from './OperationModals'; +import { OperationModals } from './OperationModals'; + interface OptionsMenuProps { setModStatus: (status: ModStatus) => void, @@ -51,7 +52,6 @@ function ModTools({ quit, modStatus, setModStatus }: { modStatus: ModStatus, setModStatus: (status: ModStatus) => void}) { const { chosenDevice } = useDeviceConnectorContext(); - const modals = useOperationModalsContext(); return ( <div id="modTools"> @@ -61,7 +61,7 @@ function ModTools({ quit, modStatus, setModStatus }: { onClick={async () => { if (!chosenDevice) return; - const setError = modals.useSetError("Failed to kill Beat Saber process"); + const setError = OperationModals.useSetError("Failed to kill Beat Saber process"); try { await chosenDevice.subprocess.noneProtocol.spawnWait(`am force-stop ${gameId}`); toast.success("Successfully killed Beat Saber"); @@ -76,7 +76,7 @@ function ModTools({ quit, modStatus, setModStatus }: { onClick={async () => { if (!chosenDevice) return; - const setError = modals.useSetError("Failed to kill Beat Saber process"); + const setError = OperationModals.useSetError("Failed to kill Beat Saber process"); try { 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"); @@ -91,7 +91,7 @@ function ModTools({ quit, modStatus, setModStatus }: { onClick={async () => { if (!chosenDevice) return; - await modals.wrapOperation( + await OperationModals.wrapOperation( "Reinstalling only core mods", "Failed to reinstall only core mods", async () => { @@ -107,7 +107,7 @@ function ModTools({ quit, modStatus, setModStatus }: { onClick={async () => { if (!chosenDevice) return; - const setError = modals.useSetError("Failed to uninstall Beat Saber"); + const setError = OperationModals.useSetError("Failed to uninstall Beat Saber"); try { await uninstallBeatSaber(chosenDevice); quit(); @@ -121,7 +121,7 @@ function ModTools({ quit, modStatus, setModStatus }: { description="Fixes an issue with player data permissions." onClick={async () => { if (!chosenDevice) return; - const setError = modals.useSetError("Failed to fix player data"); + const setError = OperationModals.useSetError("Failed to fix player data"); try { if (await fixPlayerData(chosenDevice)) { toast.success("Successfully fixed player data issues"); @@ -143,7 +143,6 @@ function RepatchMenu({ modStatus, quit }: { } ) { const { chosenDevice, devicePreV51 } = useDeviceConnectorContext(); - const { wrapOperation } = useOperationModalsContext(); let manifest = useRef(new AndroidManifest(modStatus.app_info!.manifest_xml)); useEffect(() => { @@ -159,7 +158,7 @@ function RepatchMenu({ modStatus, quit }: { <button onClick={async () => { 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! 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/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) From b6caa6d35256337a9916d2572b4ddcf0957f6624 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:21:25 -0500 Subject: [PATCH 4/4] Refactor OperationModals to not use Valtio --- mbf-site/package.json | 1 - mbf-site/src/components/OperationModals.tsx | 202 ++++++++++++-------- 2 files changed, 122 insertions(+), 81 deletions(-) diff --git a/mbf-site/package.json b/mbf-site/package.json index 91930f03..21736850 100644 --- a/mbf-site/package.json +++ b/mbf-site/package.json @@ -25,7 +25,6 @@ "react-spinners": "^0.14.1", "react-toastify": "^10.0.5", "semver": "^7.6.0", - "valtio": "^2.1.8", "vite": "^5.2.8", "vite-plugin-mkcert": "^1.17.5", "vite-tsconfig-paths": "^4.3.2", diff --git a/mbf-site/src/components/OperationModals.tsx b/mbf-site/src/components/OperationModals.tsx index 4bd02d65..befbc946 100644 --- a/mbf-site/src/components/OperationModals.tsx +++ b/mbf-site/src/components/OperationModals.tsx @@ -9,10 +9,8 @@ import { ScaleLoader } from "react-spinners"; import { LogWindow, LogWindowControls } from "./LogWindow"; import { ErrorModal } from "./Modal"; -import React from "react"; -import { proxy, useSnapshot } from "valtio"; +import React, { useLayoutEffect } from "react"; import { Log } from "../Logging"; -import { definePropertiesFromSource } from "../definePropertiesFromSource"; /** * An error that occured within a particular operation. @@ -22,11 +20,16 @@ export interface OperationError { error: string; } -const modalState = proxy<OperationModalsData>({ +const state: OperationModalsData = ({ currentOperation: null, currentError: null, statusText: null, logsManuallyOpen: false, + setCurrentOperation: undefined, + setCurrentError: undefined, + setStatusText: undefined, + setLogsManuallyOpen: undefined, + mounted: false, }); interface OperationModalsData { @@ -39,76 +42,46 @@ interface OperationModalsData { /** Whether or not the logs have been manually opened by the user. */ logsManuallyOpen: boolean; -} - -interface OperationModalsActions { - /** Creates a function that can be used to set whether or not a particular operation is currently in progress. */ - useSetWorking: typeof useSetWorking; - - /** Creates a function that can be used to set an error when a particular operation failed. */ - useSetError: typeof useSetError; - - /** Used to wrap a particular operation while displaying the logging window and any errors if appropriate. */ - wrapOperation: typeof wrapOperation; -} - -/** - * Creates a function that can be used to set whether or not a particular operation is currently in progress. - */ -function useSetWorking( - operationName: string -): (working: boolean) => void { - return function setWorking(working) { - if (working) { - modalState.currentOperation = operationName; - } else { - modalState.statusText = null; - modalState.currentOperation = null; - } - }; -} - -/** - * Creates a function that can be used to set an error when a particular operation failed. - */ -function useSetError( - errorTitle: string -): (error: unknown | null) => void { - return function setError(error) { - if (error === null) { - modalState.currentError = null; - } else { - Log.error(`${errorTitle}: ${String(error)}`); - modalState.currentError = { - title: errorTitle, - error: String(error), - }; - } - }; -} - -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); - } + + 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, logsManuallyOpen } = useSnapshot(modalState); + 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 = @@ -121,33 +94,102 @@ export function OperationModals() { title={currentOperation ?? "Log output"} subtext={statusText} onClose={ - canClose - ? () => (modalState.logsManuallyOpen = false) - : undefined + canClose ? () => (setLogsManuallyOpen(false)) : undefined } /> <ErrorModal isVisible={currentError !== null} title={currentError?.title ?? ""} description={currentError?.error} - onClose={() => (modalState.currentError = null)} + onClose={() => (setCurrentError(null))} ></ErrorModal> </> ); } -export namespace OperationModals { +export namespace OperationModals { export let currentOperation: string | null; export let currentError: OperationError | null; export let statusText: string | null; export let logsManuallyOpen: boolean; - export let useSetWorking: OperationModalsActions["useSetWorking"]; - export let useSetError: OperationModalsActions["useSetError"]; - export let wrapOperation: OperationModalsActions["wrapOperation"]; + + /** + * 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), + }; + } + }; + } + + 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); + } + } } -definePropertiesFromSource(OperationModals, modalState); -definePropertiesFromSource(OperationModals, {useSetWorking, useSetError, wrapOperation}, undefined, true); +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,