From 7d0e6efe3dbd0fa77e0d4c5185766ebc34f41c1a Mon Sep 17 00:00:00 2001 From: Avery Date: Sun, 10 Jul 2022 16:53:47 -0400 Subject: [PATCH] Add support for app properties plugins --- injected/src/app-properties-menu.ts | 145 +++++++++++++++++++++ injected/src/entrypoints/app-properties.ts | 9 +- injected/src/selectors.ts | 1 + injected/src/services/apps.ts | 49 ++++++- injected/src/services/inject.ts | 7 +- injected/src/smm.ts | 40 +++--- injected/src/types/global.d.ts | 4 +- patcher/js_library_root_sp.go | 120 +++++++++++++++-- rpc/inject/app-properties.go | 8 +- 9 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 injected/src/app-properties-menu.ts diff --git a/injected/src/app-properties-menu.ts b/injected/src/app-properties-menu.ts new file mode 100644 index 0000000..bf483d1 --- /dev/null +++ b/injected/src/app-properties-menu.ts @@ -0,0 +1,145 @@ +import { GamepadHandler } from './gamepad'; +import { BTN_CODE } from './gamepad/buttons'; +import { DECK_SELECTORS } from './selectors'; +import { SMM } from './smm'; +import { AppPropsApp } from './types/global'; + +type AppPropertiesMenuRender = ( + smm: SMM, + root: HTMLDivElement, + app: AppPropsApp +) => void | Promise; + +interface AppPropertiesMenuItem { + id: string; + title: string; + render?: AppPropertiesMenuRender; + // TODO: make this a filtering function instead + appIds?: number[]; +} + +export class AppPropertiesMenu { + private readonly smm: SMM; + private appPropsMenuOpen: boolean; + private gamepad?: GamepadHandler; + items: AppPropertiesMenuItem[]; + + constructor(smm: SMM) { + this.smm = smm; + this.appPropsMenuOpen = false; + this.items = []; + + window.csGetAppPropsMenuItems = this.getMenuItems.bind(this); + + this.smm.addEventListener( + 'switchToAppProperties', + (e: CustomEventInit) => { + if (typeof e.detail === 'undefined') { + return; + } + + const app = e.detail; + + // Make sure this event only happens once after app props is opened + if (this.appPropsMenuOpen) { + return; + } + + const appProps = document.querySelector( + DECK_SELECTORS.appProperties + ); + if (!appProps) { + return; + } + + this.appPropsMenuOpen = true; + + let curPluginPage: string | undefined = undefined; + + const observer = new MutationObserver(() => { + if (!document.querySelector(DECK_SELECTORS.appProperties)) { + this.appPropsMenuOpen = false; + observer.disconnect(); + } + + let found = false; + + for (const item of this.getMenuItems({ appid: app.appid })) { + // Check if this plugin's page is visible and we can render into it + const root = document.querySelector( + `[data-cs-plugin-id="${item.id}"]` + ); + if (root) { + // Exit if plugin is already rendered + if (item.id === curPluginPage) { + return; + } + + curPluginPage = item.id; + found = true; + + // Clear before rendering + root.innerHTML = ''; + item.render?.( + this.smm, + root, + JSON.parse(root.dataset!.csPluginData!) as AppPropsApp + ); + + if (window.smmUIMode === 'deck') { + this.smm.ButtonInterceptors.addInterceptor({ + id: `csAppPropertiesMenu`, + handler: (buttonCode) => { + if ( + !this.gamepad && + (buttonCode === BTN_CODE.A || + buttonCode === BTN_CODE.RIGHT) + ) { + this.gamepad = new GamepadHandler({ + smm: this.smm, + root, + rootExitCallback: () => { + this.gamepad?.cleanup(); + this.gamepad = undefined; + }, + }); + } + }, + }); + } + + break; + } + } + + if (!found) { + curPluginPage = undefined; + this.gamepad?.cleanup(); + this.gamepad = undefined; + this.smm.ButtonInterceptors.removeInterceptor( + `csAppPropertiesMenu` + ); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + ); + } + + addMenuItem(item: AppPropertiesMenuItem) { + this.items.push(item); + } + + removeMenuItem(id: string) { + this.items = this.items.filter((item) => item.id !== id); + } + + private getMenuItems(app: Pick) { + return this.items.filter((item) => + item.appIds ? item.appIds.includes(app.appid) : true + ); + } +} diff --git a/injected/src/entrypoints/app-properties.ts b/injected/src/entrypoints/app-properties.ts index fff21bd..e9b7e37 100644 --- a/injected/src/entrypoints/app-properties.ts +++ b/injected/src/entrypoints/app-properties.ts @@ -1,5 +1,6 @@ import { SHARED_SELECTORS } from '../selectors'; import { SMM } from '../smm'; +import { AppPropsApp } from '../types/global'; import { info, waitForElement } from '../util'; const main = async () => { @@ -19,10 +20,10 @@ const main = async () => { await smm.loadPlugins(); - const appPropertiesAppId: number | undefined = (window as any) - .appPropertiesAppId; - if (appPropertiesAppId) { - smm.switchToAppProperties(appPropertiesAppId); + const appPropertiesApp: AppPropsApp | undefined = (window as any) + .appPropertiesApp; + if (appPropertiesApp) { + smm.switchToAppProperties(appPropertiesApp); } else { console.error('App ID for app properties context not found.'); } diff --git a/injected/src/selectors.ts b/injected/src/selectors.ts index 6f81c9e..26d1acc 100644 --- a/injected/src/selectors.ts +++ b/injected/src/selectors.ts @@ -17,6 +17,7 @@ export const DECK_SELECTORS = { quickAccessContainer: '[class*=quickaccessmenu_Container_]', topLevelTransitionSwitch: '[class^=topleveltransitionswitch_TopLevelTransitionSwitch_]', + appProperties: '[class*=appproperties_AppProperties]', }; const createModeSelectors = < diff --git a/injected/src/services/apps.ts b/injected/src/services/apps.ts index 7fb6692..5cd737c 100644 --- a/injected/src/services/apps.ts +++ b/injected/src/services/apps.ts @@ -8,13 +8,52 @@ interface AppDetails { _fullDetails: Record; } +enum ipcNames { + getCachedDetailsForApp = 'csAppsGetCachedDetailsForApp', + getCachedDetailsForAppRes = 'csAppsGetCachedDetailsForAppRes', +} + export class Apps extends Service { + constructor(...args: ConstructorParameters) { + super(...args); + + if (this.smm.entry === 'library') { + this.smm.IPC.on<{ appId: number }>( + ipcNames.getCachedDetailsForApp, + async ({ data: { appId } }) => { + this.smm.IPC.send( + ipcNames.getCachedDetailsForAppRes, + await this.getCachedDetailsForApp(appId) + ); + } + ); + } + } + /* - Get app details for an app ID from Steam's cache. - TODO: get info for uncached app - TODO: support this from other contexts (IPC probably) - */ - getCachedDetailsForApp(appId: number): AppDetails | undefined { + Get app details for an app ID from Steam's cache. + TODO: get info for uncached app + TODO: support this from other contexts (IPC probably) + */ + async getCachedDetailsForApp(appId: number): Promise { + if (this.smm.entry !== 'library') { + this.smm.IPC.send<{ appId: number }>(ipcNames.getCachedDetailsForApp, { + appId, + }); + const appDetails = await new Promise( + (resolve) => { + this.smm.IPC.on( + ipcNames.getCachedDetailsForAppRes, + ({ data }) => { + resolve(data); + this.smm.IPC.off(ipcNames.getCachedDetailsForAppRes); + } + ); + } + ); + return appDetails; + } + const appData = window.appDetailsStore.m_mapAppData._data.get(appId)?.value.details; if (!appData) { diff --git a/injected/src/services/inject.ts b/injected/src/services/inject.ts index df51161..4e194fd 100644 --- a/injected/src/services/inject.ts +++ b/injected/src/services/inject.ts @@ -1,14 +1,15 @@ import { rpcRequest } from '../rpc'; +import { AppPropsApp } from '../types/global'; import { Service } from './service'; export class Inject extends Service { - async injectAppProperties(appId: number) { + async injectAppProperties(app: AppPropsApp) { const { getRes } = rpcRequest< { - appId: number; + app: string; }, {} - >('InjectService.InjectAppProperties', { appId }); + >('InjectService.InjectAppProperties', { app: JSON.stringify(app) }); return getRes(); } } diff --git a/injected/src/smm.ts b/injected/src/smm.ts index 8be32a3..ca5638f 100644 --- a/injected/src/smm.ts +++ b/injected/src/smm.ts @@ -1,3 +1,4 @@ +import { AppPropertiesMenu } from './app-properties-menu'; import { GamepadHandler } from './gamepad'; import { ButtonInterceptors } from './gamepad/button-interceptors'; import { InGameMenu } from './in-game-menu'; @@ -12,6 +13,7 @@ import { Plugins } from './services/plugins'; import { Store } from './services/store'; import { Toast } from './services/toast'; import { UI } from './services/ui'; +import { AppPropsApp } from './types/global'; import { info } from './util'; type PluginId = string; @@ -52,9 +54,8 @@ class EventSwitchToAppDetails extends CustomEvent { - constructor(detail: eventDetailsSwitchToAppProperties) { +class EventSwitchToAppProperties extends CustomEvent { + constructor(detail: AppPropsApp) { super('switchToAppProperties', { detail }); } } @@ -86,6 +87,7 @@ export class SMM extends EventTarget { // TODO: improve types for running in context without menu readonly MenuManager!: MenuManager; readonly InGameMenu!: InGameMenu; + readonly AppPropertiesMenu!: AppPropertiesMenu; readonly FS: FS; readonly Plugins: Plugins; readonly IPC: IPC; @@ -94,8 +96,7 @@ export class SMM extends EventTarget { readonly Inject: Inject; readonly Store: Store; readonly ButtonInterceptors: ButtonInterceptors; - // TODO: related to types for specific context above, use inherited classes - readonly Apps?: Apps; + readonly Apps: Apps; readonly serverPort: string; @@ -134,16 +135,23 @@ export class SMM extends EventTarget { this.Inject = new Inject(this); this.Store = new Store(this); this.ButtonInterceptors = new ButtonInterceptors(this); + this.Apps = new Apps(this); if (entry === 'library') { this.MenuManager = new MenuManager(this); - this.Apps = new Apps(this); } if (entry === 'library' || entry === 'quickAccess') { this.InGameMenu = new InGameMenu(this); } + if ( + entry === 'library' || + (window.smmUIMode === 'desktop' && entry === 'appProperties') + ) { + this.AppPropertiesMenu = new AppPropertiesMenu(this); + } + this.serverPort = window.smmServerPort; this.attachedEvents = {}; @@ -206,8 +214,8 @@ export class SMM extends EventTarget { this.dispatchEvent(new EventSwitchToAppDetails({ appId, appName })); } - switchToAppProperties(appId: number) { - info('Switched to app properties for app', appId); + switchToAppProperties(app: AppPropsApp) { + info('Switched to app properties for app', app.appid); /* In desktop mode, the app properties dialog is in a different context, so @@ -218,11 +226,11 @@ export class SMM extends EventTarget { */ if (window.smmUIMode === 'desktop' && this.entry !== 'appProperties') { - this.Inject.injectAppProperties(appId); + this.Inject.injectAppProperties(app); return; } - this.dispatchEvent(new EventSwitchToAppProperties({ appId })); + this.dispatchEvent(new EventSwitchToAppProperties(app)); } lockScreenOpened() { @@ -311,16 +319,14 @@ export class SMM extends EventTarget { callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions ): void { - if (!this.currentPlugin) { - console.error('[SMM] addEventListener missing this.currentPlugin!'); - return; - } + if (this.currentPlugin) { + if (!this.attachedEvents[this.currentPlugin]) { + this.attachedEvents[this.currentPlugin] = []; + } - if (!this.attachedEvents[this.currentPlugin]) { - this.attachedEvents[this.currentPlugin] = []; + this.attachedEvents[this.currentPlugin].push({ type, callback, options }); } - this.attachedEvents[this.currentPlugin].push({ type, callback, options }); super.addEventListener(type, callback, options); } diff --git a/injected/src/types/global.d.ts b/injected/src/types/global.d.ts index d4917a7..76290b5 100644 --- a/injected/src/types/global.d.ts +++ b/injected/src/types/global.d.ts @@ -31,8 +31,8 @@ export interface AppPropsApp { display_status: number; installed: boolean; is_available_on_current_platform: boolean; - status_percentage: number - }, + status_percentage: number; + }; size_on_disk: string; sort_as: string; steam_deck_compat_category: number; diff --git a/patcher/js_library_root_sp.go b/patcher/js_library_root_sp.go index 32d6f61..1a168dd 100644 --- a/patcher/js_library_root_sp.go +++ b/patcher/js_library_root_sp.go @@ -62,7 +62,7 @@ func patchLibraryRootSP(scriptPath, serverPort, cacheDir string, noCache bool) e return err } - fileLines, err = appPropertiesEvent(fileLines) + fileLines, err = appProperties(fileLines) if err != nil { return err } @@ -190,7 +190,7 @@ func addButtonInterceptor(fileLines []string) ([]string, error) { return fileLines, nil } -func appPropertiesEvent(fileLines []string) ([]string, error) { +func appProperties(fileLines []string) ([]string, error) { titleLine := -1 for i, line := range fileLines { if strings.Contains(line, "#AppProperties_ShortcutPage") { @@ -203,30 +203,51 @@ func appPropertiesEvent(fileLines []string) ([]string, error) { return nil, errors.New("Didn't find app properties title line") } - getAppPropsLine := regexp.MustCompile(`GetAppOverviewByAppID\(([a-zA-Z0-9]+)\)`) + getAppPropsLine := regexp.MustCompile(`\s([a-zA-Z0-9]+)\.app_type`) found := false - appId := "" - for i := titleLine - 1; i >= titleLine-15; i-- { + app := "" + for i := titleLine - 1; i >= titleLine-5; i-- { line := fileLines[i] matches := getAppPropsLine.FindStringSubmatch(line) if len(matches) >= 2 { found = true - appId = matches[1] + app = matches[1] break } } if !found { - return nil, errors.New("Didn't find GetAppOverviewByAppID") + return nil, errors.New("Didn't find app") + } + + createElementRe := regexp.MustCompile(` ([a-zA-Z0-9]+)\.createElement`) + react := "" + for i := titleLine; i < titleLine+20; i++ { + line := fileLines[i] + + if matches := createElementRe.FindStringSubmatch(line); len(matches) >= 2 { + react = matches[1] + break + } + } + + if react == "" { + return nil, errors.New("Didn't find createElementRe") } for i := titleLine - 1; i >= titleLine-10; i-- { line := fileLines[i] if strings.HasPrefix(strings.TrimSpace(line), "return") { found = true - fileLines[i] = fmt.Sprintf("smm.switchToAppProperties(%s);\n", appId) + fileLines[i] + fileLines[i] = fmt.Sprintf(` + const [, updateState] = %[1]s.useState(); + window.csAppPropsMenuUpdate = %[1]s.useCallback(() => { + updateState({}); + }, [updateState]); + smm.switchToAppProperties(%[2]s); + `, react, app) + fileLines[i] break } } @@ -235,5 +256,88 @@ func appPropertiesEvent(fileLines []string) ([]string, error) { return nil, errors.New("Didn't find return") } + feedbackLine := -1 + for i, line := range fileLines { + if strings.Contains(line, `"#AppProperties_FeedbackPage"`) { + feedbackLine = i + break + } + } + if feedbackLine < 0 { + return nil, errors.New("Didn't find feedback line") + } + + log.Println("feedbackLine", feedbackLine) + + itemsExp := regexp.MustCompile(`\S ([a-zA-Z0-9]+)\.push\(\{`) + items := "" + for i := feedbackLine - 1; i > feedbackLine-3; i-- { + line := fileLines[i] + + if matches := itemsExp.FindStringSubmatch(line); len(matches) >= 2 { + items = matches[1] + break + } + } + + if items == "" { + return nil, errors.New("Didn't find items") + } + + matchRe := regexp.MustCompile(`^\s+className: .+AppProperties,$`) + appPropsLine := -1 + for i := feedbackLine + 1; i < feedbackLine+15; i++ { + if match := matchRe.MatchString(fileLines[i]); match { + appPropsLine = i + break + } + } + if appPropsLine < 0 { + return nil, errors.New("Didn't find appProps line") + } + + log.Println("appPropsLine", appPropsLine, fileLines[appPropsLine]) + + createLine := -1 + for i := appPropsLine; i > appPropsLine-10; i-- { + line := fileLines[i] + if matches := createElementRe.FindStringSubmatch(line); len(matches) >= 2 { + createLine = i + break + } + } + + if createLine < 0 { + return nil, errors.New("Didn't find create line") + } + + line := strings.TrimSpace(fileLines[createLine]) + + if !strings.HasPrefix(line, "})") { + return nil, errors.New("Error patching app properties") + } + + fileLines[createLine] = "}), " + fmt.Sprintf(`( + window.csGetAppPropsMenuItems + ? %[1]s.push( + ...( + window.csGetAppPropsMenuItems() + .map((item) => { + return ({ + ...item, + link: "/app/"+%[2]s.appid+"/properties/"+item.id, + route: "/app/:appid/properties/"+item.id, + content: %[3]s.createElement('div', { + "data-cs-plugin-id": item.id, + "data-cs-plugin-data": JSON.stringify(%[2]s), + }, null), + }); + } + ) + ) + ) + : undefined + )`, items, app, react) + strings.TrimPrefix(line, "})") + return fileLines, nil } diff --git a/rpc/inject/app-properties.go b/rpc/inject/app-properties.go index 214b942..33add49 100644 --- a/rpc/inject/app-properties.go +++ b/rpc/inject/app-properties.go @@ -13,13 +13,13 @@ import ( ) type InjectAppPropertiesArgs struct { - AppId int + App string } type InjectAppPropertiesReply struct{} var appPropertiesScriptTemplate = template.Must(template.New("app-properties").Parse(` - window.appPropertiesAppId = {{ .AppId }}; + window.appPropertiesApp = JSON.parse(` + "`{{ .App }}`" + `); {{ .Script }} `)) @@ -65,10 +65,10 @@ func (service *InjectService) InjectAppProperties(r *http.Request, req *InjectAp var scriptBytes bytes.Buffer if err := appPropertiesScriptTemplate.Execute(&scriptBytes, struct { - AppId int + App string Script string }{ - AppId: req.AppId, + App: req.App, Script: appPropertiesEvalScript, }); err != nil { return err