From 90f89a02443085b6eb7e3537d9dca0a1f0145dd7 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:39:27 +0700 Subject: [PATCH] Show local co-op icon in game card --- .gitignore | 7 +- .vscode/settings.json | 1 + dist/better-xcloud.lite.user.js | 31 +++++++- dist/better-xcloud.user.js | 74 ++++++++++++++++++- src/assets/svg/local-co-op.svg | 7 ++ src/enums/pref-keys.ts | 1 + src/modules/patcher/patcher-utils.ts | 46 ++++++++++++ src/modules/patcher/patcher.ts | 54 ++++++++++++++ .../patcher/patches/src/game-card-icons.ts | 9 +++ src/utils/bx-event-bus.ts | 6 ++ src/utils/bx-exposed.ts | 22 ++++++ src/utils/gh-pages.ts | 20 +++++ src/utils/local-co-op-manager.ts | 21 ++++++ .../global-settings-storage.ts | 2 +- 14 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 src/assets/svg/local-co-op.svg create mode 100644 src/modules/patcher/patches/src/game-card-icons.ts create mode 100644 src/utils/local-co-op-manager.ts diff --git a/.gitignore b/.gitignore index 5ada0f1..536f7e4 100755 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,4 @@ -src/modules/patcher/patches/controller-customization.js -src/modules/patcher/patches/expose-stream-session.js -src/modules/patcher/patches/local-co-op-enable.js -src/modules/patcher/patches/poll-gamepad.js -src/modules/patcher/patches/remote-play-keep-alive.js -src/modules/patcher/patches/vibration-adjust.js +src/modules/patcher/patches/*.js # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ca1742..76212a7 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "dist/**/*": true, "src/modules/patcher/patches/controller-customization.js": true, "src/modules/patcher/patches/expose-stream-session.js": true, + "src/modules/patcher/patches/game-card-icons.js": true, "src/modules/patcher/patches/local-co-op-enable.js": true, "src/modules/patcher/patches/poll-gamepad.js": true, "src/modules/patcher/patches/remote-play-keep-alive.js": true, diff --git a/dist/better-xcloud.lite.user.js b/dist/better-xcloud.lite.user.js index 564558e..00fab23 100755 --- a/dist/better-xcloud.lite.user.js +++ b/dist/better-xcloud.lite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Better xCloud (Lite) // @namespace https://github.com/redphx -// @version 6.1.2-beta +// @version 6.1.3-beta // @description Improve Xbox Cloud Gaming (xCloud) experience // @author redphx // @license MIT @@ -105,7 +105,7 @@ class UserAgent { }); } } -var SCRIPT_VERSION = "6.1.2-beta", SCRIPT_VARIANT = "lite", AppInterface = window.AppInterface; +var SCRIPT_VERSION = "6.1.3-beta", SCRIPT_VARIANT = "lite", AppInterface = window.AppInterface; UserAgent.init(); var userAgent = window.navigator.userAgent.toLowerCase(), isTv = userAgent.includes("smart-tv") || userAgent.includes("smarttv") || /\baft.*\b/.test(userAgent), isVr = window.navigator.userAgent.includes("VR") && window.navigator.userAgent.includes("OculusBrowser"), browserHasTouchSupport = "ontouchstart" in window || navigator.maxTouchPoints > 0, userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport, STATES = { supportedRegion: !0, @@ -247,6 +247,13 @@ class GhPagesUtils { if (Array.isArray(json)) window.localStorage.setItem(key, JSON.stringify(json)); }), JSON.parse(window.localStorage.getItem(key) || "[]"); } + static getLocalCoOpList() { + let key = "BetterXcloud.GhPages.LocalCoOp"; + return NATIVE_FETCH(GhPagesUtils.getUrl("local-co-op/ids.json")).then((response) => response.json()).then((json) => { + if (json.$schemaVersion === 1) window.localStorage.setItem(key, JSON.stringify(json)), BxEventBus.Script.emit("list.localCoOp.updated", { data: json }); + else window.localStorage.removeItem(key), BxEventBus.Script.emit("list.localCoOp.updated", { data: { data: {} } }); + }), JSON.parse(window.localStorage.getItem(key) || "[]"); + } } var SUPPORTED_LANGUAGES = { "en-US": "English (US)", @@ -1622,7 +1629,7 @@ class GlobalSettingsStorage extends BaseSettingsStore { default: [], unsupported: !AppInterface && UserAgent.isMobile(), ready: (setting) => { - if (!setting.unsupported) setting.multipleOptions = GhPagesUtils.getNativeMkbCustomList(!0), BxEventBus.Script.on("list.forcedNativeMkb.updated", (payload) => { + if (!setting.unsupported) setting.multipleOptions = GhPagesUtils.getNativeMkbCustomList(!0), BxEventBus.Script.once("list.forcedNativeMkb.updated", (payload) => { setting.multipleOptions = payload.data.data; }); }, @@ -5002,6 +5009,19 @@ if (blockFeatures.includes("chat")) FeatureGates.EnableGuideChatTab = !1; if (blockFeatures.includes("friends")) FeatureGates.EnableFriendsAndFollowers = !1; if (blockFeatures.includes("byog")) FeatureGates.EnableBYOG = !1, FeatureGates.EnableBYOGPurchase = !1; if (BX_FLAGS.FeatureGates) FeatureGates = Object.assign(BX_FLAGS.FeatureGates, FeatureGates); +class LocalCoOpManager { + static instance; + static getInstance = () => LocalCoOpManager.instance ?? (LocalCoOpManager.instance = new LocalCoOpManager); + supportedIds = []; + constructor() { + BxEventBus.Script.once("list.localCoOp.updated", (e) => { + this.supportedIds = Object.keys(e.data.data), console.log("supportedIds", this.supportedIds); + }), GhPagesUtils.getLocalCoOpList(); + } + isSupported(productId) { + return this.supportedIds.includes(productId); + } +} var BxExposed = { getTitleInfo: () => STATES.currentStream.titleInfo, modifyPreloadedState: !1, @@ -5047,7 +5067,10 @@ var BxExposed = { / /g ], toggleLocalCoOp(enable) {}, - beforePageLoad: () => {} + beforePageLoad: () => {}, + localCoOpManager: LocalCoOpManager.getInstance(), + reactCreateElement: function(...args) {}, + createReactLocalCoOpIcon: () => {} }; function localRedirect(path) { let url = window.location.href.substring(0, 31) + path, $pageContent = document.getElementById("PageContent"); diff --git a/dist/better-xcloud.user.js b/dist/better-xcloud.user.js index d5ee5bc..24da865 100755 --- a/dist/better-xcloud.user.js +++ b/dist/better-xcloud.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Better xCloud // @namespace https://github.com/redphx -// @version 6.1.2-beta +// @version 6.1.3-beta // @description Improve Xbox Cloud Gaming (xCloud) experience // @author redphx // @license MIT @@ -107,7 +107,7 @@ class UserAgent { }); } } -var SCRIPT_VERSION = "6.1.2-beta", SCRIPT_VARIANT = "full", AppInterface = window.AppInterface; +var SCRIPT_VERSION = "6.1.3-beta", SCRIPT_VARIANT = "full", AppInterface = window.AppInterface; UserAgent.init(); var userAgent = window.navigator.userAgent.toLowerCase(), isTv = userAgent.includes("smart-tv") || userAgent.includes("smarttv") || /\baft.*\b/.test(userAgent), isVr = window.navigator.userAgent.includes("VR") && window.navigator.userAgent.includes("OculusBrowser"), browserHasTouchSupport = "ontouchstart" in window || navigator.maxTouchPoints > 0, userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport, STATES = { supportedRegion: !0, @@ -279,6 +279,13 @@ class GhPagesUtils { if (Array.isArray(json)) window.localStorage.setItem(key, JSON.stringify(json)); }), JSON.parse(window.localStorage.getItem(key) || "[]"); } + static getLocalCoOpList() { + let key = "BetterXcloud.GhPages.LocalCoOp"; + return NATIVE_FETCH(GhPagesUtils.getUrl("local-co-op/ids.json")).then((response) => response.json()).then((json) => { + if (json.$schemaVersion === 1) window.localStorage.setItem(key, JSON.stringify(json)), BxEventBus.Script.emit("list.localCoOp.updated", { data: json }); + else window.localStorage.removeItem(key), BxEventBus.Script.emit("list.localCoOp.updated", { data: { data: {} } }); + }), JSON.parse(window.localStorage.getItem(key) || "[]"); + } } var SUPPORTED_LANGUAGES = { "en-US": "English (US)", @@ -1694,7 +1701,7 @@ class GlobalSettingsStorage extends BaseSettingsStore { default: [], unsupported: !AppInterface && UserAgent.isMobile(), ready: (setting) => { - if (!setting.unsupported) setting.multipleOptions = GhPagesUtils.getNativeMkbCustomList(!0), BxEventBus.Script.on("list.forcedNativeMkb.updated", (payload) => { + if (!setting.unsupported) setting.multipleOptions = GhPagesUtils.getNativeMkbCustomList(!0), BxEventBus.Script.once("list.forcedNativeMkb.updated", (payload) => { setting.multipleOptions = payload.data.data; }); }, @@ -4303,6 +4310,7 @@ class BxSelectElement extends HTMLSelectElement { var controller_customization_default = "var shareButtonPressed=currentGamepad.buttons[17]?.pressed,shareButtonHandled=!1,xCloudGamepad=$xCloudGamepadVar$;if(currentGamepad.id in window.BX_STREAM_SETTINGS.controllers){let controller=window.BX_STREAM_SETTINGS.controllers[currentGamepad.id];if(controller?.customization){let{mapping,ranges}=controller.customization,pressedButtons={},releasedButtons={},isModified=!1;if(ranges.LeftTrigger){let[from,to]=ranges.LeftTrigger;xCloudGamepad.LeftTrigger=xCloudGamepad.LeftTrigger>to?1:xCloudGamepad.LeftTrigger,xCloudGamepad.LeftTrigger=xCloudGamepad.LeftTriggerto?1:xCloudGamepad.RightTrigger,xCloudGamepad.RightTrigger=xCloudGamepad.RightTriggerto?1:range;if(newRange=newRangeto?1:range;if(newRange=newRange=0.1)pressedButtons[targetX]=rangeX,pressedButtons[targetY]=rangeY}releasedButtons[sourceX]=0,releasedButtons[sourceY]=0,isModified=!0}else if(typeof mappedKey===\"string\"){let pressed=!1,value=0;if(key===\"LeftTrigger\"||key===\"RightTrigger\"){let currentRange=xCloudGamepad[key];if(mappedKey===\"LeftTrigger\"||mappedKey===\"RightTrigger\")pressed=currentRange>=0.1,value=currentRange;else pressed=!0,value=currentRange>=0.9?1:0}else if(xCloudGamepad[key])pressed=!0,value=xCloudGamepad[key];if(pressed)pressedButtons[mappedKey]=value,releasedButtons[key]=0,isModified=!0}else if(mappedKey===!1)pressedButtons[key]=0,isModified=!0}isModified&&Object.assign(xCloudGamepad,releasedButtons,pressedButtons)}}if(shareButtonPressed&&!shareButtonHandled)window.dispatchEvent(new Event(BxEvent.CAPTURE_SCREENSHOT));\n"; var poll_gamepad_default = "var self=this;if(window.BX_EXPOSED.disableGamepadPolling){self.inputConfiguration.useIntervalWorkerThreadForInput&&self.intervalWorker?self.intervalWorker.scheduleTimer(50):self.pollGamepadssetTimeoutTimerID=window.setTimeout(self.pollGamepads,50);return}var currentGamepad=$gamepadVar$,btnHome=currentGamepad.buttons[16];if(btnHome){if(!self.bxHomeStates)self.bxHomeStates={};let intervalMs=0,hijack=!1;if(btnHome.pressed)if(hijack=!0,intervalMs=16,self.gamepadIsIdle.set(currentGamepad.index,!1),self.bxHomeStates[currentGamepad.index]){let lastTimestamp=self.bxHomeStates[currentGamepad.index].timestamp;if(currentGamepad.timestamp!==lastTimestamp){if(self.bxHomeStates[currentGamepad.index].timestamp=currentGamepad.timestamp,window.BX_EXPOSED.handleControllerShortcut(currentGamepad))self.bxHomeStates[currentGamepad.index].shortcutPressed+=1}}else window.BX_EXPOSED.resetControllerShortcut(currentGamepad.index),self.bxHomeStates[currentGamepad.index]={shortcutPressed:0,timestamp:currentGamepad.timestamp};else if(self.bxHomeStates[currentGamepad.index]){hijack=!0;let info=structuredClone(self.bxHomeStates[currentGamepad.index]);if(self.bxHomeStates[currentGamepad.index]=null,info.shortcutPressed===0){let fakeGamepadMappings=[{GamepadIndex:currentGamepad.index,A:0,B:0,X:0,Y:0,LeftShoulder:0,RightShoulder:0,LeftTrigger:0,RightTrigger:0,View:0,Menu:0,LeftThumb:0,RightThumb:0,DPadUp:0,DPadDown:0,DPadLeft:0,DPadRight:0,Nexus:1,LeftThumbXAxis:0,LeftThumbYAxis:0,RightThumbXAxis:0,RightThumbYAxis:0,PhysicalPhysicality:0,VirtualPhysicality:0,Dirty:!0,Virtual:!1}];intervalMs=currentGamepad.timestamp-info.timestamp>=500?500:100,self.inputSink.onGamepadInput(performance.now()-intervalMs,fakeGamepadMappings)}else intervalMs=window.BX_STREAM_SETTINGS.controllerPollingRate}if(hijack&&intervalMs){self.inputConfiguration.useIntervalWorkerThreadForInput&&self.intervalWorker?self.intervalWorker.scheduleTimer(intervalMs):self.pollGamepadssetTimeoutTimerID=setTimeout(self.pollGamepads,intervalMs);return}}\n"; var expose_stream_session_default = 'var self=this;window.BX_EXPOSED.streamSession=self;var orgSetMicrophoneState=self.setMicrophoneState.bind(self);self.setMicrophoneState=(state)=>{orgSetMicrophoneState(state),window.BxEventBus.Stream.emit("microphone.state.changed",{state})};window.dispatchEvent(new Event(BxEvent.STREAM_SESSION_READY));var updateDimensionsStr=self.updateDimensions.toString();if(updateDimensionsStr.startsWith("function "))updateDimensionsStr=updateDimensionsStr.substring(9);var renderTargetVar=updateDimensionsStr.match(/if\\((\\w+)\\){/)[1];updateDimensionsStr=updateDimensionsStr.replaceAll(renderTargetVar+".scroll","scroll");updateDimensionsStr=updateDimensionsStr.replace(`if(${renderTargetVar}){`,`\nif (${renderTargetVar}) {\nconst scrollWidth = ${renderTargetVar}.dataset.width ? parseInt(${renderTargetVar}.dataset.width) : ${renderTargetVar}.scrollWidth;\nconst scrollHeight = ${renderTargetVar}.dataset.height ? parseInt(${renderTargetVar}.dataset.height) : ${renderTargetVar}.scrollHeight;\n`);eval(`this.updateDimensions = function ${updateDimensionsStr}`);\n'; +var game_card_icons_default = `var supportedInputIcons=$supportedInputIcons$,{productId}=$param$;if(window.BX_EXPOSED.localCoOpManager.isSupported(productId))supportedInputIcons.push(()=>window.BX_EXPOSED.createReactLocalCoOpIcon());`; var local_co_op_enable_default = 'this.orgOnGamepadChanged=this.onGamepadChanged;this.orgOnGamepadInput=this.onGamepadInput;var match,onGamepadChangedStr=this.onGamepadChanged.toString();if(onGamepadChangedStr.startsWith("function "))onGamepadChangedStr=onGamepadChangedStr.substring(9);onGamepadChangedStr=onGamepadChangedStr.replaceAll("0","arguments[1]");eval(`this.patchedOnGamepadChanged = function ${onGamepadChangedStr}`);var onGamepadInputStr=this.onGamepadInput.toString();if(onGamepadInputStr.startsWith("function "))onGamepadInputStr=onGamepadInputStr.substring(9);match=onGamepadInputStr.match(/(\\w+\\.GamepadIndex)/);if(match){let gamepadIndexVar=match[0];onGamepadInputStr=onGamepadInputStr.replace("this.gamepadStates.get(",`this.gamepadStates.get(${gamepadIndexVar},`),eval(`this.patchedOnGamepadInput = function ${onGamepadInputStr}`),BxLogger.info("supportLocalCoOp","✅ Successfully patched local co-op support")}else BxLogger.error("supportLocalCoOp","❌ Unable to patch local co-op support");this.toggleLocalCoOp=(enable)=>{BxLogger.info("toggleLocalCoOp",enable?"Enabled":"Disabled"),this.onGamepadChanged=enable?this.patchedOnGamepadChanged:this.orgOnGamepadChanged,this.onGamepadInput=enable?this.patchedOnGamepadInput:this.orgOnGamepadInput;let gamepads=window.navigator.getGamepads();for(let gamepad of gamepads){if(!gamepad?.connected)continue;if(gamepad.id.includes("Better xCloud"))continue;window.dispatchEvent(new GamepadEvent("gamepaddisconnected",{gamepad})),window.dispatchEvent(new GamepadEvent("gamepadconnected",{gamepad}))}};window.BX_EXPOSED.toggleLocalCoOp=this.toggleLocalCoOp.bind(null);\n'; var remote_play_keep_alive_default = `try{if(JSON.parse(e).reason==="WarningForBeingIdle"&&!window.location.pathname.includes("/launch/")){this.sendKeepAlive();return}}catch(ex){console.log(ex)}`; var vibration_adjust_default = `if(e?.gamepad?.connected){let gamepadSettings=window.BX_STREAM_SETTINGS.controllers[e.gamepad.id];if(gamepadSettings?.customization){let intensity=gamepadSettings.customization.vibrationIntensity;if(intensity<=0){e.repeat=0;return}else if(intensity<1)e.leftMotorPercent*=intensity,e.rightMotorPercent*=intensity,e.leftTriggerMotorPercent*=intensity,e.rightTriggerMotorPercent*=intensity}}`; @@ -4333,6 +4341,24 @@ class PatcherUtils { if (!str.includes(text)) return !1; return str = str.replace("requireAsync(e){", `requireAsync(e){window.BX_EXPOSED.beforePageLoad("${page}");`), str = str.replace("requireSync(e){", `requireSync(e){window.BX_EXPOSED.beforePageLoad("${page}");`), str; } + static isVarCharacter(char) { + let code = char.charCodeAt(0), isUppercase = code >= 65 && code <= 90, isLowercase = code >= 97 && code <= 122, isDigit = code >= 48 && code <= 57; + return isUppercase || isLowercase || isDigit || (char === "_" || char === "$"); + } + static getVariableNameBefore(str, index) { + if (index < 0) return null; + let end = index, start = end - 1; + while (PatcherUtils.isVarCharacter(str[start])) + start -= 1; + return str.substring(start + 1, end); + } + static getVariableNameAfter(str, index) { + if (index < 0) return null; + let start = index, end = start + 1; + while (PatcherUtils.isVarCharacter(str[end])) + end += 1; + return str.substring(start, end); + } } var LOG_TAG2 = "Patcher", PATCHES = { disableAiTrack(str) { @@ -4802,6 +4828,27 @@ subs = subs.filter(val => !${JSON.stringify(filters)}.includes(val)); ${subsVar} = subs; `; return str = PatcherUtils.insertAt(str, index, newCode), str; + }, + exposeReactCreateComponent(str) { + let index = str.indexOf(".prototype.isReactComponent={}"); + if (index > -1 && (index = PatcherUtils.indexOf(str, ".createElement=", index)), index < 0) return !1; + let newCode = "window.BX_EXPOSED.reactCreateElement="; + return str = PatcherUtils.insertAt(str, index - 1, newCode), str; + }, + gameCardCustomIcons(str) { + let initialIndex = str.indexOf("const{supportedInputIcons:"); + if (initialIndex < 0) return !1; + let returnIndex = PatcherUtils.lastIndexOf(str, "return ", str.indexOf("SupportedInputsBadge")); + if (returnIndex < 0) return !1; + let arrowIndex = PatcherUtils.lastIndexOf(str, "=>{", initialIndex, 300); + if (arrowIndex < 0) return !1; + let paramVar = PatcherUtils.getVariableNameBefore(str, arrowIndex), supportedInputIconsVar = PatcherUtils.getVariableNameAfter(str, PatcherUtils.indexOf(str, "supportedInputIcons:", initialIndex, 100, !0)); + if (!paramVar || !supportedInputIconsVar) return !1; + let newCode = renderString(game_card_icons_default, { + param: paramVar, + supportedInputIcons: supportedInputIconsVar + }); + return str = PatcherUtils.insertAt(str, returnIndex, newCode), str; } }, PATCH_ORDERS = PatcherUtils.filterPatches([ ...AppInterface && getPref("nativeMkb.mode") === "on" ? [ @@ -4809,6 +4856,8 @@ ${subsVar} = subs; "exposeInputSink", "disableAbsoluteMouse" ] : [], + "exposeReactCreateComponent", + "gameCardCustomIcons", "modifyPreloadedState", "optimizeGameSlugGenerator", "detectBrowserRouterReady", @@ -7387,6 +7436,19 @@ if (blockFeatures.includes("chat")) FeatureGates.EnableGuideChatTab = !1; if (blockFeatures.includes("friends")) FeatureGates.EnableFriendsAndFollowers = !1; if (blockFeatures.includes("byog")) FeatureGates.EnableBYOG = !1, FeatureGates.EnableBYOGPurchase = !1; if (BX_FLAGS.FeatureGates) FeatureGates = Object.assign(BX_FLAGS.FeatureGates, FeatureGates); +class LocalCoOpManager { + static instance; + static getInstance = () => LocalCoOpManager.instance ?? (LocalCoOpManager.instance = new LocalCoOpManager); + supportedIds = []; + constructor() { + BxEventBus.Script.once("list.localCoOp.updated", (e) => { + this.supportedIds = Object.keys(e.data.data), console.log("supportedIds", this.supportedIds); + }), GhPagesUtils.getLocalCoOpList(); + } + isSupported(productId) { + return this.supportedIds.includes(productId); + } +} var BxExposed = { getTitleInfo: () => STATES.currentStream.titleInfo, modifyPreloadedState: (state) => { @@ -7492,6 +7554,12 @@ var BxExposed = { toggleLocalCoOp(enable) {}, beforePageLoad: (page) => { BxLogger.info("beforePageLoad", page), Patcher.patchPage(page); + }, + localCoOpManager: LocalCoOpManager.getInstance(), + reactCreateElement: function(...args) {}, + createReactLocalCoOpIcon: () => { + let reactCE = window.BX_EXPOSED.reactCreateElement; + return reactCE("svg", { xmlns: "http://www.w3.org/2000/svg", width: "1em", height: "1em", viewBox: "0 0 32 32", "fill-rule": "evenodd", "stroke-linecap": "round", "stroke-linejoin": "round" }, reactCE("g", null, reactCE("path", { d: "M24.272 11.165h-3.294l-3.14 3.564c-.391.391-.922.611-1.476.611a2.1 2.1 0 0 1-2.087-2.088 2.09 2.09 0 0 1 .031-.362l1.22-6.274a3.89 3.89 0 0 1 3.81-3.206h6.57c1.834 0 3.439 1.573 3.833 3.295l1.205 6.185a2.09 2.09 0 0 1 .031.362 2.1 2.1 0 0 1-2.087 2.088c-.554 0-1.085-.22-1.476-.611l-3.14-3.564", fill: "none", stroke: "#fff", "stroke-width": "2" }), reactCE("circle", { cx: "22.625", cy: "5.874", r: ".879" }), reactCE("path", { d: "M11.022 24.415H7.728l-3.14 3.564c-.391.391-.922.611-1.476.611a2.1 2.1 0 0 1-2.087-2.088 2.09 2.09 0 0 1 .031-.362l1.22-6.274a3.89 3.89 0 0 1 3.81-3.206h6.57c1.834 0 3.439 1.573 3.833 3.295l1.205 6.185a2.09 2.09 0 0 1 .031.362 2.1 2.1 0 0 1-2.087 2.088c-.554 0-1.085-.22-1.476-.611l-3.14-3.564", fill: "none", stroke: "#fff", "stroke-width": "2" }), reactCE("circle", { cx: "9.375", cy: "19.124", r: ".879" }))); } }; function localRedirect(path) { diff --git a/src/assets/svg/local-co-op.svg b/src/assets/svg/local-co-op.svg new file mode 100644 index 0000000..74535ea --- /dev/null +++ b/src/assets/svg/local-co-op.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/enums/pref-keys.ts b/src/enums/pref-keys.ts index bc84df1..d1893c8 100755 --- a/src/enums/pref-keys.ts +++ b/src/enums/pref-keys.ts @@ -12,6 +12,7 @@ export const enum StorageKey { GH_PAGES_COMMIT_HASH = 'BetterXcloud.GhPages.CommitHash', LIST_CUSTOM_TOUCH_LAYOUTS = 'BetterXcloud.GhPages.CustomTouchLayouts', LIST_FORCE_NATIVE_MKB = 'BetterXcloud.GhPages.ForceNativeMkb', + LIST_LOCAL_CO_OP = 'BetterXcloud.GhPages.LocalCoOp', } diff --git a/src/modules/patcher/patcher-utils.ts b/src/modules/patcher/patcher-utils.ts index ab94621..109468b 100644 --- a/src/modules/patcher/patcher-utils.ts +++ b/src/modules/patcher/patcher-utils.ts @@ -50,4 +50,50 @@ export class PatcherUtils { return str; } + + private static isVarCharacter(char: string) { + const code = char.charCodeAt(0); + + // Check for uppercase letters (A-Z) + const isUppercase = code >= 65 && code <= 90; + + // Check for lowercase letters (a-z) + const isLowercase = code >= 97 && code <= 122; + + // Check for digits (0-9) + const isDigit = code >= 48 && code <= 57; + + // Check for special characters '_' and '$' + const isSpecial = char === '_' || char === '$'; + + return isUppercase || isLowercase || isDigit || isSpecial; + } + + static getVariableNameBefore(str: string, index: number) { + if (index < 0) { + return null; + } + + const end = index; + let start = end - 1; + while (PatcherUtils.isVarCharacter(str[start])) { + start -= 1; + } + + return str.substring(start + 1, end); + } + + static getVariableNameAfter(str: string, index: number) { + if (index < 0) { + return null; + } + + const start = index; + let end = start + 1; + while (PatcherUtils.isVarCharacter(str[end])) { + end += 1; + } + + return str.substring(start, end); + } } diff --git a/src/modules/patcher/patcher.ts b/src/modules/patcher/patcher.ts index 8e4ac04..f7e8438 100755 --- a/src/modules/patcher/patcher.ts +++ b/src/modules/patcher/patcher.ts @@ -7,6 +7,7 @@ import { BxEvent } from "@/utils/bx-event"; import codeControllerCustomization from "./patches/controller-customization.js" with { type: "text" }; import codePollGamepad from "./patches/poll-gamepad.js" with { type: "text" }; import codeExposeStreamSession from "./patches/expose-stream-session.js" with { type: "text" }; +import codeGameCardIcons from "./patches/game-card-icons.js" with { type: "text" }; import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" }; import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with { type: "text" }; import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" }; @@ -1003,6 +1004,56 @@ ${subsVar} = subs; str = PatcherUtils.insertAt(str, index, newCode); return str; }, + + exposeReactCreateComponent(str: string) { + let index = str.indexOf('.prototype.isReactComponent={}'); + + index > -1 && (index = PatcherUtils.indexOf(str, '.createElement=', index)); + if (index < 0) { + return false; + } + + const newCode = 'window.BX_EXPOSED.reactCreateElement='; + str = PatcherUtils.insertAt(str, index - 1, newCode); + + return str; + }, + + // 27.0.6-hotfix.1, 73704.js + gameCardCustomIcons(str: string) { + let initialIndex = str.indexOf('const{supportedInputIcons:'); + if (initialIndex < 0) { + return false; + } + + const returnIndex = PatcherUtils.lastIndexOf(str, 'return ', str.indexOf('SupportedInputsBadge')); + if (returnIndex < 0) { + return false; + } + + // Find function's parameter + const arrowIndex = PatcherUtils.lastIndexOf(str, '=>{', initialIndex, 300); + if (arrowIndex < 0) { + return false; + } + + const paramVar = PatcherUtils.getVariableNameBefore(str, arrowIndex); + + // Find supportedInputIcons and title var names + const supportedInputIconsVar = PatcherUtils.getVariableNameAfter(str, PatcherUtils.indexOf(str, 'supportedInputIcons:', initialIndex, 100, true)); + + if (!paramVar || !supportedInputIconsVar) { + return false; + } + + const newCode = renderString(codeGameCardIcons, { + param: paramVar, + supportedInputIcons: supportedInputIconsVar, + }); + + str = PatcherUtils.insertAt(str, returnIndex, newCode); + return str; + }, }; let PATCH_ORDERS = PatcherUtils.filterPatches([ @@ -1012,6 +1063,9 @@ let PATCH_ORDERS = PatcherUtils.filterPatches([ 'disableAbsoluteMouse', ] : []), + 'exposeReactCreateComponent', + 'gameCardCustomIcons', + 'modifyPreloadedState', 'optimizeGameSlugGenerator', diff --git a/src/modules/patcher/patches/src/game-card-icons.ts b/src/modules/patcher/patches/src/game-card-icons.ts new file mode 100644 index 0000000..066518b --- /dev/null +++ b/src/modules/patcher/patches/src/game-card-icons.ts @@ -0,0 +1,9 @@ +declare const $supportedInputIcons$: Array; +declare const $param$: { productId: string }; + +const supportedInputIcons = $supportedInputIcons$; +const { productId } = $param$; + +if (window.BX_EXPOSED.localCoOpManager.isSupported(productId)) { + supportedInputIcons.push(() => window.BX_EXPOSED.createReactLocalCoOpIcon()); +} diff --git a/src/utils/bx-event-bus.ts b/src/utils/bx-event-bus.ts index a2e940f..6a60f43 100644 --- a/src/utils/bx-event-bus.ts +++ b/src/utils/bx-event-bus.ts @@ -31,6 +31,12 @@ type ScriptEvents = { data: any; }; }; + + 'list.localCoOp.updated': { + data: { + data: any; + }; + }; }; type StreamEvents = { diff --git a/src/utils/bx-exposed.ts b/src/utils/bx-exposed.ts index b4c2f51..0fbd8c3 100755 --- a/src/utils/bx-exposed.ts +++ b/src/utils/bx-exposed.ts @@ -13,6 +13,7 @@ import { NativeMkbMode, TouchControllerMode } from "@/enums/pref-values"; import { Patcher, type PatchPage } from "@/modules/patcher/patcher"; import { BxEventBus } from "./bx-event-bus"; import { FeatureGates } from "./feature-gates"; +import { LocalCoOpManager } from "./local-co-op-manager"; export enum SupportedInputType { CONTROLLER = 'Controller', @@ -230,4 +231,25 @@ export const BxExposed = { BxLogger.info('beforePageLoad', page); Patcher.patchPage(page); } : () => {}, + + localCoOpManager: LocalCoOpManager.getInstance(), + reactCreateElement: function(...args: any[]) {}, + + createReactLocalCoOpIcon: isFullVersion() ? (): any => { + const reactCE = window.BX_EXPOSED.reactCreateElement; + + // local-co-op.svg + return reactCE( + 'svg', + { xmlns: 'http://www.w3.org/2000/svg', width: '1em', height: '1em', viewBox: '0 0 32 32', 'fill-rule': 'evenodd', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, + reactCE( + 'g', + null, + reactCE('path', { d: 'M24.272 11.165h-3.294l-3.14 3.564c-.391.391-.922.611-1.476.611a2.1 2.1 0 0 1-2.087-2.088 2.09 2.09 0 0 1 .031-.362l1.22-6.274a3.89 3.89 0 0 1 3.81-3.206h6.57c1.834 0 3.439 1.573 3.833 3.295l1.205 6.185a2.09 2.09 0 0 1 .031.362 2.1 2.1 0 0 1-2.087 2.088c-.554 0-1.085-.22-1.476-.611l-3.14-3.564', fill: 'none', stroke: '#fff', 'stroke-width': '2' }), + reactCE('circle', { cx: '22.625', cy: '5.874', r: '.879' }), + reactCE('path', { d: 'M11.022 24.415H7.728l-3.14 3.564c-.391.391-.922.611-1.476.611a2.1 2.1 0 0 1-2.087-2.088 2.09 2.09 0 0 1 .031-.362l1.22-6.274a3.89 3.89 0 0 1 3.81-3.206h6.57c1.834 0 3.439 1.573 3.833 3.295l1.205 6.185a2.09 2.09 0 0 1 .031.362 2.1 2.1 0 0 1-2.087 2.088c-.554 0-1.085-.22-1.476-.611l-3.14-3.564', fill: 'none', stroke: '#fff', 'stroke-width': '2' }), + reactCE('circle', { cx: '9.375', cy: '19.124', r: '.879' }) + ), + ); + } : () => {}, }; diff --git a/src/utils/gh-pages.ts b/src/utils/gh-pages.ts index e40e312..a34fe8b 100755 --- a/src/utils/gh-pages.ts +++ b/src/utils/gh-pages.ts @@ -83,4 +83,24 @@ export class GhPagesUtils { const customList = JSON.parse(window.localStorage.getItem(key) || '[]'); return customList; } + + static getLocalCoOpList() { + const supportedSchema = 1; + const key = StorageKey.LIST_LOCAL_CO_OP; + + NATIVE_FETCH(GhPagesUtils.getUrl('local-co-op/ids.json')) + .then(response => response.json()) + .then(json => { + if (json.$schemaVersion === supportedSchema) { + window.localStorage.setItem(key, JSON.stringify(json)); + BxEventBus.Script.emit('list.localCoOp.updated', { data: json }); + } else { + window.localStorage.removeItem(key); + BxEventBus.Script.emit('list.localCoOp.updated', { data: { data: {} } }); + } + }); + + const customList = JSON.parse(window.localStorage.getItem(key) || '[]'); + return customList; + } } diff --git a/src/utils/local-co-op-manager.ts b/src/utils/local-co-op-manager.ts new file mode 100644 index 0000000..3749f89 --- /dev/null +++ b/src/utils/local-co-op-manager.ts @@ -0,0 +1,21 @@ +import { BxEventBus } from "./bx-event-bus"; +import { GhPagesUtils } from "./gh-pages"; + +export class LocalCoOpManager { + private static instance: LocalCoOpManager; + public static getInstance = () => LocalCoOpManager.instance ?? (LocalCoOpManager.instance = new LocalCoOpManager()); + + private supportedIds: string[] = []; + + constructor() { + BxEventBus.Script.once('list.localCoOp.updated', e => { + this.supportedIds = Object.keys(e.data.data); + console.log('supportedIds', this.supportedIds); + }); + GhPagesUtils.getLocalCoOpList(); + } + + isSupported(productId: string) { + return this.supportedIds.includes(productId); + } +} diff --git a/src/utils/settings-storages/global-settings-storage.ts b/src/utils/settings-storages/global-settings-storage.ts index 86a1785..002b3d4 100755 --- a/src/utils/settings-storages/global-settings-storage.ts +++ b/src/utils/settings-storages/global-settings-storage.ts @@ -436,7 +436,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { if (!setting.unsupported) { (setting as any).multipleOptions = GhPagesUtils.getNativeMkbCustomList(true); - BxEventBus.Script.on('list.forcedNativeMkb.updated', payload => { + BxEventBus.Script.once('list.forcedNativeMkb.updated', payload => { (setting as any).multipleOptions = payload.data.data; }); }