Compare commits

..

15 Commits

18 changed files with 1754 additions and 1619 deletions

View File

@ -1,5 +1,5 @@
// ==UserScript==
// @name Better xCloud
// @namespace https://github.com/redphx
// @version 5.1.3
// @version 5.2.0
// ==/UserScript==

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,6 @@ import { PrefKey, getPref } from "@utils/preferences";
import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
import { TouchController } from "@modules/touch-controller";
import { watchHeader } from "@modules/ui/header";
import { checkForUpdate, disablePwa } from "@utils/utils";
import { Patcher } from "@modules/patcher";
import { RemotePlay } from "@modules/remote-play";
@ -34,6 +33,7 @@ import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu";
import { StreamSettings } from "./modules/stream/stream-settings";
import { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
import { UiSection } from "./enums/ui-sections";
import { HeaderSection } from "./modules/ui/header";
// Handle login page
@ -125,13 +125,13 @@ window.addEventListener('popstate', onHistoryChanged);
window.history.pushState = patchHistoryMethod('pushState');
window.history.replaceState = patchHistoryMethod('replaceState');
window.addEventListener(BxEvent.XCLOUD_SERVERS_UNAVAILABLE, e => {
STATES.supportedRegion = false;
window.setTimeout(HeaderSection.watchHeader, 2000);
});
window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => {
// Start rendering UI
if (document.querySelector('div[class^=UnsupportedMarketPage]')) {
window.setTimeout(watchHeader, 2000);
} else {
watchHeader();
}
HeaderSection.watchHeader();
});
window.addEventListener(BxEvent.STREAM_LOADING, e => {
@ -186,6 +186,14 @@ window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
});
window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => {
const $elm = (e as any).element;
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || ($elm.tagName === 'A' && $elm.className.includes('GameCard'))) {
console.dir($elm);
}
});
function unload() {
if (!STATES.isPlaying) {
return;
@ -340,6 +348,9 @@ function main() {
STATES.pointerServerPort = AppInterface.startPointerServer() || 9269;
BxLogger.info('startPointerServer', 'Port', STATES.pointerServerPort.toString());
}
// Preload Remote Play
getPref(PrefKey.REMOTE_PLAY_ENABLED) && BX_FLAGS.PreloadRemotePlay && RemotePlay.preload();
}
main();

View File

@ -9,6 +9,7 @@ import { BxEvent } from "@/utils/bx-event";
import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" };
import codeExposeStreamSession from "./patches/expose-stream-session.js" with { type: "text" };
import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" };
import codeSetCurrentlyFocusedInteractable from "./patches/set-currently-focused-interactable.js" with { type: "text" };
import codeRemotePlayEnable from "./patches/remote-play-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" };
@ -775,6 +776,18 @@ if (this.baseStorageKey in window.BX_EXPOSED.overrideSettings) {
str = str.substring(0, commaIndex) + ',true' + str.substring(index);
return str;
},
// 24225.js#4127, 24.17.11
patchSetCurrentlyFocusedInteractable(str: string) {
let index = str.indexOf('.setCurrentlyFocusedInteractable=(');
if (index === -1) {
return false;
}
index = str.indexOf('{', index) + 1;
str = str.substring(0, index) + codeSetCurrentlyFocusedInteractable + str.substring(index);
return str;
},
};
let PATCH_ORDERS: PatchArray = [
@ -797,6 +810,7 @@ let PATCH_ORDERS: PatchArray = [
'enableTvRoutes',
'overrideStorageGetSettings',
// 'patchSetCurrentlyFocusedInteractable',
getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout',
getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp',
@ -851,7 +865,7 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
...(STATES.userAgent.capabilities.touch ? [
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
(getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer',
(getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF) || !STATES.userAgent.capabilities.touch) && 'disableTakRenderer',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),

View File

@ -0,0 +1 @@
e && BxEvent.dispatch(window, BxEvent.NAVIGATION_FOCUS_CHANGED, {element: e});

View File

@ -7,6 +7,7 @@ import { getPref, PrefKey, setPref } from "@utils/preferences";
import { t } from "@utils/translation";
import { localRedirect } from "@modules/ui/ui";
import { BxLogger } from "@utils/bx-logger";
import { HeaderSection } from "./ui/header";
const LOG_TAG = 'RemotePlay';
@ -97,6 +98,10 @@ export class RemotePlay {
RemotePlay.#getXhomeToken(() => {
RemotePlay.#getConsolesList(() => {
BxLogger.info(LOG_TAG, 'Consoles', RemotePlay.#CONSOLES);
if (RemotePlay.#CONSOLES && RemotePlay.#CONSOLES.length > 0) {
STATES.supportedRegion && HeaderSection.showRemotePlayButton();
}
RemotePlay.#renderConsoles();
BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY);
});

View File

@ -15,6 +15,7 @@ const SETTINGS_UI = {
'Better xCloud': {
items: [
PrefKey.BETTER_XCLOUD_LOCALE,
PrefKey.SERVER_BYPASS_RESTRICTION,
PrefKey.REMOTE_PLAY_ENABLED,
],
},
@ -260,9 +261,9 @@ export function setupSettingsUi() {
if (setting.experimental) {
settingLabel = '🧪 ' + settingLabel;
if (!settingNote) {
settingNote = t('experimental')
settingNote = t('experimental');
} else {
settingNote = `${t('experimental')}: ${settingNote}`
settingNote = `${t('experimental')}: ${settingNote}`;
}
}
@ -343,6 +344,8 @@ export function setupSettingsUi() {
$control.appendChild($option);
}
$control.disabled = Object.keys(STATES.serverRegions).length === 0;
// Select preferred region
$control.value = selectedValue;
} else {
@ -428,6 +431,13 @@ export function setupSettingsUi() {
}, `❤️ ${t('support-better-xcloud')}`);
$wrapper.appendChild($donationLink);
// Show Game Pass app version
try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content;
const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10);
$wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`));
} catch (e) {}
$container.appendChild($wrapper);
// Add Settings UI to the web page

View File

@ -1,5 +1,5 @@
import { SCRIPT_VERSION } from "@utils/global";
import { createButton, ButtonStyle } from "@utils/html";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region";
import { PrefKey, getPref } from "@utils/preferences";
@ -7,35 +7,20 @@ import { RemotePlay } from "@modules/remote-play";
import { t } from "@utils/translation";
import { setupSettingsUi } from "./global-settings";
export class HeaderSection {
static #$remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button', 'bx-gone'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
onClick: e => {
RemotePlay.togglePopup();
},
});
function injectSettingsButton($parent?: HTMLElement) {
if (!$parent) {
return;
}
const PREF_PREFERRED_REGION = getPreferredServerRegion(true);
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
const $headerFragment = document.createDocumentFragment();
// Remote Play button
if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) {
const $remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
onClick: e => {
RemotePlay.togglePopup();
},
});
$headerFragment.appendChild($remotePlayBtn);
}
// Setup Settings button
const $settingsBtn = createButton({
static #$settingsBtn = createButton({
classes: ['bx-header-settings-button'],
label: PREF_PREFERRED_REGION,
label: '???',
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => {
setupSettingsUi();
@ -47,39 +32,61 @@ function injectSettingsButton($parent?: HTMLElement) {
},
});
// Show new update status
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) {
$settingsBtn.setAttribute('data-update-available', 'true');
static #$buttonsWrapper = CE('div', {},
getPref(PrefKey.REMOTE_PLAY_ENABLED) ? HeaderSection.#$remotePlayBtn : null,
HeaderSection.#$settingsBtn,
);
static #observer: MutationObserver;
static #injectSettingsButton($parent?: HTMLElement) {
if (!$parent) {
return;
}
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
// Setup Settings button
const $settingsBtn = HeaderSection.#$settingsBtn;
$settingsBtn.querySelector('span')!.textContent = getPreferredServerRegion(true);
// Show new update status
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) {
$settingsBtn.setAttribute('data-update-available', 'true');
}
// Add the Settings button to the web page
$parent.appendChild(HeaderSection.#$buttonsWrapper);
}
// Add the Settings button to the web page
$headerFragment.appendChild($settingsBtn);
$parent.appendChild($headerFragment);
}
static checkHeader() {
const $button = document.querySelector('.bx-header-settings-button');
export function checkHeader() {
const $button = document.querySelector('.bx-header-settings-button');
if (!$button) {
const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
injectSettingsButton($rightHeader as HTMLElement);
}
}
export function watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
if (!$button) {
const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
HeaderSection.#injectSettingsButton($rightHeader as HTMLElement);
}
}
let timeout: number | null;
const observer = new MutationObserver(mutationList => {
timeout && clearTimeout(timeout);
timeout = window.setTimeout(checkHeader, 2000);
});
observer.observe($header, {subtree: true, childList: true});
static showRemotePlayButton() {
HeaderSection.#$remotePlayBtn.classList.remove('bx-gone');
}
checkHeader();
static watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
}
let timeout: number | null;
HeaderSection.#observer && HeaderSection.#observer.disconnect();
HeaderSection.#observer = new MutationObserver(mutationList => {
timeout && clearTimeout(timeout);
timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
});
HeaderSection.#observer.observe($header, {subtree: true, childList: true});
HeaderSection.checkHeader();
}
}

View File

@ -24,6 +24,8 @@ interface NavigatorBattery extends Navigator {
}
type BxStates = {
supportedRegion: boolean;
isPlaying: boolean;
appContext: any | null;
serverRegions: any;

View File

@ -26,6 +26,7 @@ export enum BxEvent {
REMOTE_PLAY_FAILED = 'bx-remote-play-failed',
XCLOUD_SERVERS_READY = 'bx-servers-ready',
XCLOUD_SERVERS_UNAVAILABLE = 'bx-servers-unavailable',
DATA_CHANNEL_CREATED = 'bx-data-channel-created',
@ -38,6 +39,8 @@ export enum BxEvent {
POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested',
POINTER_LOCK_EXITED = 'bx-pointer-lock-exited',
NAVIGATION_FOCUS_CHANGED = 'bx-nav-focus-changed',
// xCloud Dialog events
XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown',
XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed',

View File

@ -3,6 +3,7 @@ import { getPref, PrefKey } from "./preferences";
export let FeatureGates: {[key: string]: boolean} = {
'PwaPrompt': false,
'EnableWifiWarnings': false,
};
// Disable context menu in Home page

View File

@ -13,6 +13,8 @@ const browserHasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoi
const userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport;
export const STATES: BxStates = {
supportedRegion: true,
isPlaying: false,
appContext: {},
serverRegions: {},

View File

@ -1,7 +1,7 @@
import { BxEvent } from "@utils/bx-event";
import { LoadingScreen } from "@modules/loading-screen";
import { RemotePlay } from "@modules/remote-play";
import { checkHeader } from "@modules/ui/header";
import { HeaderSection } from "@/modules/ui/header";
export function patchHistoryMethod(type: 'pushState' | 'replaceState') {
const orig = window.history[type];
@ -34,7 +34,7 @@ export function onHistoryChanged(e: PopStateEvent) {
RemotePlay.detachPopup();
LoadingScreen.reset();
window.setTimeout(checkHeader, 2000);
window.setTimeout(HeaderSection.watchHeader, 2000);
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
}

View File

@ -1,16 +1,13 @@
import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags";
import { LoadingScreen } from "@modules/loading-screen";
import { PrefKey, getPref } from "@utils/preferences";
import { RemotePlay } from "@modules/remote-play";
import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller";
import { STATES } from "@utils/global";
import { getPreferredServerRegion } from "@utils/region";
import { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { InputType } from "./bx-exposed";
import { FeatureGates } from "./feature-gates";
import { BxLogger } from "./bx-logger";
import { XhomeInterceptor } from "./xhome-interceptor";
import { XcloudInterceptor } from "./xcloud-interceptor";
enum RequestType {
XCLOUD = 'xcloud',
@ -101,7 +98,7 @@ function updateIceCandidates(candidates: any, options: any) {
}
async function patchIceCandidates(request: Request, consoleAddrs?: {[index: string]: number}) {
export async function patchIceCandidates(request: Request, consoleAddrs?: {[index: string]: number}) {
const response = await NATIVE_FETCH(request);
const text = await response.clone().text();
@ -125,383 +122,6 @@ async function patchIceCandidates(request: Request, consoleAddrs?: {[index: stri
return response;
}
class XhomeInterceptor {
static #consoleAddrs: {[index: string]: number} = {};
static async #handleLogin(request: Request) {
try {
const clone = (request as Request).clone();
const obj = await clone.json();
obj.offeringId = 'xhome';
request = new Request('https://xhome.gssv-play-prod.xboxlive.com/v2/login/user', {
method: 'POST',
body: JSON.stringify(obj),
headers: {
'Content-Type': 'application/json',
},
});
} catch (e) {
alert(e);
console.log(e);
}
return NATIVE_FETCH(request);
}
static async #handleConfiguration(request: Request | URL) {
const response = await NATIVE_FETCH(request);
const obj = await response.clone().json()
console.log(obj);
const serverDetails = obj.serverDetails;
if (serverDetails.ipAddress) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipAddress] = serverDetails.port;
}
if (serverDetails.ipV4Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port;
}
if (serverDetails.ipV6Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV6Address] = serverDetails.ipV6Port;
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) {
const response = await NATIVE_FETCH(request);
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'all') {
return response;
}
const obj = await response.clone().json() as any;
const xboxTitleId = JSON.parse(opts.body).titleIds[0];
STATES.currentStream.xboxTitleId = xboxTitleId;
const inputConfigs = obj[0];
let hasTouchSupport = inputConfigs.supportedTabs.length > 0;
if (!hasTouchSupport) {
const supportedInputTypes = inputConfigs.supportedInputTypes;
hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY);
}
if (hasTouchSupport) {
TouchController.disable();
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, {
data: null,
});
} else {
TouchController.enable();
TouchController.getCustomLayouts(xboxTitleId);
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleTitles(request: Request) {
const clone = request.clone();
const headers: {[index: string]: any} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
headers.authorization = `Bearer ${RemotePlay.XCLOUD_TOKEN}`;
const index = request.url.indexOf('.xboxlive.com');
request = new Request('https://wus.core.gssv-play-prod' + request.url.substring(index), {
method: clone.method,
body: await clone.text(),
headers: headers,
});
return NATIVE_FETCH(request);
}
static async #handlePlay(request: RequestInfo | URL) {
const clone = (request as Request).clone();
const body = await clone.json();
// body.settings.useIceConnection = true;
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async handle(request: Request) {
TouchController.disable();
const clone = request.clone();
const headers: {[index: string]: string} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
// Add xHome token to headers
headers.authorization = `Bearer ${RemotePlay.XHOME_TOKEN}`;
// Patch resolution
const deviceInfo = RemotePlay.BASE_DEVICE_INFO;
if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === '720p') {
deviceInfo.dev.os.name = 'android';
}
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);
const opts: {[index: string]: any} = {
method: clone.method,
headers: headers,
};
if (clone.method === 'POST') {
opts.body = await clone.text();
}
let newUrl = request.url;
if (!newUrl.includes('/servers/home')) {
const index = request.url.indexOf('.xboxlive.com');
newUrl = STATES.remotePlay.server + request.url.substring(index + 13);
}
request = new Request(newUrl, opts);
let url = (typeof request === 'string') ? request : request.url;
// Get console IP
if (url.includes('/configuration')) {
return XhomeInterceptor.#handleConfiguration(request);
} else if (url.endsWith('/sessions/home/play')) {
return XhomeInterceptor.#handlePlay(request);
} else if (url.includes('inputconfigs')) {
return XhomeInterceptor.#handleInputConfigs(request, opts);
} else if (url.includes('/login/user')) {
return XhomeInterceptor.#handleLogin(request);
} else if (url.endsWith('/titles')) {
return XhomeInterceptor.#handleTitles(request);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request, XhomeInterceptor.#consoleAddrs);
}
return await NATIVE_FETCH(request);
}
}
class XcloudInterceptor {
static async #handleLogin(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json();
// Preload Remote Play
getPref(PrefKey.REMOTE_PLAY_ENABLED) && BX_FLAGS.PreloadRemotePlay && RemotePlay.preload();
// Store xCloud token
RemotePlay.XCLOUD_TOKEN = obj.gsToken;
// Get server list
const serverEmojis = {
AustraliaEast: '🇦🇺',
AustraliaSouthEast: '🇦🇺',
BrazilSouth: '🇧🇷',
EastUS: '🇺🇸',
EastUS2: '🇺🇸',
JapanEast: '🇯🇵',
KoreaCentral: '🇰🇷',
MexicoCentral: '🇲🇽',
NorthCentralUs: '🇺🇸',
SouthCentralUS: '🇺🇸',
UKSouth: '🇬🇧',
WestEurope: '🇪🇺',
WestUS: '🇺🇸',
WestUS2: '🇺🇸',
};
const serverRegex = /\/\/(\w+)\./;
for (let region of obj.offeringSettings.regions) {
const regionName = region.name as keyof typeof serverEmojis;
let shortName = region.name;
let match = serverRegex.exec(region.baseUri);
if (match) {
shortName = match[1];
if (serverEmojis[regionName]) {
shortName = serverEmojis[regionName] + ' ' + shortName;
}
}
region.shortName = shortName.toUpperCase();
STATES.serverRegions[region.name] = Object.assign({}, region);
}
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_READY);
const preferredRegion = getPreferredServerRegion();
if (preferredRegion in STATES.serverRegions) {
const tmp = Object.assign({}, STATES.serverRegions[preferredRegion]);
tmp.isDefault = true;
obj.offeringSettings.regions = [tmp];
}
response.json = () => Promise.resolve(obj);
return response;
}
static async #handlePlay(request: RequestInfo | URL, init?: RequestInit) {
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
let badgeRegion: string = parsedUrl.host.split('.', 1)[0];
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
if (parsedUrl.origin == region.baseUri) {
badgeRegion = regionName;
break;
}
}
StreamBadges.getInstance().setRegion(badgeRegion);
const clone = (request as Request).clone();
const body = await clone.json();
// Force stream's resolution
if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') {
const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows';
body.settings.osName = osName;
}
// Override "locale" value
if (PREF_STREAM_PREFERRED_LOCALE !== 'default') {
body.settings.locale = PREF_STREAM_PREFERRED_LOCALE;
}
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async #handleWaitTime(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) {
const json = await response.clone().json();
if (json.estimatedAllocationTimeInSeconds > 0) {
// Setup wait time overlay
LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds);
}
}
return response;
}
static async #handleConfiguration(request: RequestInfo | URL, init?: RequestInit) {
if ((request as Request).method !== 'GET') {
return NATIVE_FETCH(request, init);
}
// Touch controller for all games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
} else {
TouchController.enable();
}
}
// Intercept configurations
const response = await NATIVE_FETCH(request, init);
const text = await response.clone().text();
if (!text.length) {
return response;
}
const obj = JSON.parse(text);
let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {};
overrides.inputConfiguration = overrides.inputConfiguration || {};
overrides.inputConfiguration.enableVibration = true;
let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true;
}
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
overrideMkb = false;
}
if (overrideMkb !== null) {
overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, {
enableMouseInput: overrideMkb,
enableKeyboardInput: overrideMkb,
});
}
// Enable touch controller
if (TouchController.isEnabled()) {
overrides.inputConfiguration.enableTouchInput = true;
overrides.inputConfiguration.maxTouchPoints = 10;
}
// Enable mic
if (getPref(PrefKey.AUDIO_MIC_ON_PLAYING)) {
overrides.audioConfiguration = overrides.audioConfiguration || {};
overrides.audioConfiguration.enableMicrophone = true;
}
obj.clientStreamingConfigOverrides = JSON.stringify(overrides);
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async handle(request: RequestInfo | URL, init?: RequestInit) {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Server list
if (url.endsWith('/v2/login/user')) {
return XcloudInterceptor.#handleLogin(request, init);
} else if (url.endsWith('/sessions/cloud/play')) { // Get session
return XcloudInterceptor.#handlePlay(request, init);
} else if (url.includes('xboxlive.com') && url.includes('/waittime/')) {
return XcloudInterceptor.#handleWaitTime(request, init);
} else if (url.endsWith('/configuration')) {
return XcloudInterceptor.#handleConfiguration(request, init);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request as Request);
}
return NATIVE_FETCH(request, init);
}
}
export function interceptHttpRequests() {
let BLOCKED_URLS: string[] = [];
if (getPref(PrefKey.BLOCK_TRACKING)) {

View File

@ -1,5 +1,5 @@
import { CE } from "@utils/html";
import { SUPPORTED_LANGUAGES, t } from "@utils/translation";
import { SUPPORTED_LANGUAGES, t, ut } from "@utils/translation";
import { SettingElement, SettingElementType } from "@utils/settings";
import { UserAgent } from "@utils/user-agent";
import { StreamStat } from "@modules/stream/stream-stats";
@ -17,6 +17,8 @@ export enum PrefKey {
BETTER_XCLOUD_LOCALE = 'bx_locale',
SERVER_REGION = 'server_region',
SERVER_BYPASS_RESTRICTION = 'server_bypass_restriction',
PREFER_IPV6_SERVER = 'prefer_ipv6_server',
STREAM_TARGET_RESOLUTION = 'stream_target_resolution',
STREAM_PREFERRED_LOCALE = 'stream_preferred_locale',
@ -122,6 +124,12 @@ export class Preferences {
label: t('region'),
default: 'default',
},
[PrefKey.SERVER_BYPASS_RESTRICTION]: {
label: ut('Bypass region restriction'),
note: ut('⚠️ Use this at your own risk'),
default: false,
},
[PrefKey.STREAM_PREFERRED_LOCALE]: {
label: t('preferred-game-language'),
default: 'default',

View File

@ -51,6 +51,7 @@ const Texts = {
"bottom-right": "Bottom-right",
"brightness": "Brightness",
"browser-unsupported-feature": "Your browser doesn't support this feature",
"bypass-region-restriction": "Bypass region restriction",
"can-stream-xbox-360-games": "Can stream Xbox 360 games",
"cancel": "Cancel",
"cant-stream-xbox-360-games": "Can't stream Xbox 360 games",
@ -277,6 +278,7 @@ const Texts = {
"unmuted": "Unmuted",
"unsharp-masking": "Unsharp masking",
"use-mouse-absolute-position": "Use mouse's absolute position",
"use-this-at-your-own-risk": "Use this at your own risk",
"user-agent-profile": "User-Agent profile",
"vertical-scroll-sensitivity": "Vertical scroll sensitivity",
"vertical-sensitivity": "Vertical sensitivity",

View File

@ -0,0 +1,216 @@
import { LoadingScreen } from "@modules/loading-screen";
import { RemotePlay } from "@modules/remote-play";
import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller";
import { BxEvent } from "./bx-event";
import { NATIVE_FETCH, BX_FLAGS } from "./bx-flags";
import { STATES } from "./global";
import { patchIceCandidates } from "./network";
import { getPref, PrefKey } from "./preferences";
import { getPreferredServerRegion } from "./region";
export
class XcloudInterceptor {
static async #handleLogin(request: RequestInfo | URL, init?: RequestInit) {
if (getPref(PrefKey.SERVER_BYPASS_RESTRICTION)) {
(request as Request).headers.set('X-Forwarded-For', '9.9.9.9');
}
const response = await NATIVE_FETCH(request, init);
if (response.status !== 200) {
// Unsupported region
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_UNAVAILABLE);
return response;
}
const obj = await response.clone().json();
// Store xCloud token
RemotePlay.XCLOUD_TOKEN = obj.gsToken;
// Get server list
const serverEmojis = {
AustraliaEast: '🇦🇺',
AustraliaSouthEast: '🇦🇺',
BrazilSouth: '🇧🇷',
EastUS: '🇺🇸',
EastUS2: '🇺🇸',
JapanEast: '🇯🇵',
KoreaCentral: '🇰🇷',
MexicoCentral: '🇲🇽',
NorthCentralUs: '🇺🇸',
SouthCentralUS: '🇺🇸',
UKSouth: '🇬🇧',
WestEurope: '🇪🇺',
WestUS: '🇺🇸',
WestUS2: '🇺🇸',
};
const serverRegex = /\/\/(\w+)\./;
for (let region of obj.offeringSettings.regions) {
const regionName = region.name as keyof typeof serverEmojis;
let shortName = region.name;
let match = serverRegex.exec(region.baseUri);
if (match) {
shortName = match[1];
if (serverEmojis[regionName]) {
shortName = serverEmojis[regionName] + ' ' + shortName;
}
}
region.shortName = shortName.toUpperCase();
STATES.serverRegions[region.name] = Object.assign({}, region);
}
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_READY);
const preferredRegion = getPreferredServerRegion();
if (preferredRegion in STATES.serverRegions) {
const tmp = Object.assign({}, STATES.serverRegions[preferredRegion]);
tmp.isDefault = true;
obj.offeringSettings.regions = [tmp];
}
response.json = () => Promise.resolve(obj);
return response;
}
static async #handlePlay(request: RequestInfo | URL, init?: RequestInit) {
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
let badgeRegion: string = parsedUrl.host.split('.', 1)[0];
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
if (parsedUrl.origin == region.baseUri) {
badgeRegion = regionName;
break;
}
}
StreamBadges.getInstance().setRegion(badgeRegion);
const clone = (request as Request).clone();
const body = await clone.json();
// Force stream's resolution
if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') {
const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows';
body.settings.osName = osName;
}
// Override "locale" value
if (PREF_STREAM_PREFERRED_LOCALE !== 'default') {
body.settings.locale = PREF_STREAM_PREFERRED_LOCALE;
}
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async #handleWaitTime(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) {
const json = await response.clone().json();
if (json.estimatedAllocationTimeInSeconds > 0) {
// Setup wait time overlay
LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds);
}
}
return response;
}
static async #handleConfiguration(request: RequestInfo | URL, init?: RequestInit) {
if ((request as Request).method !== 'GET') {
return NATIVE_FETCH(request, init);
}
// Touch controller for all games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
} else {
TouchController.enable();
}
}
// Intercept configurations
const response = await NATIVE_FETCH(request, init);
const text = await response.clone().text();
if (!text.length) {
return response;
}
const obj = JSON.parse(text);
let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {};
overrides.inputConfiguration = overrides.inputConfiguration || {};
overrides.inputConfiguration.enableVibration = true;
let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true;
}
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
overrideMkb = false;
}
if (overrideMkb !== null) {
overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, {
enableMouseInput: overrideMkb,
enableKeyboardInput: overrideMkb,
});
}
// Enable touch controller
if (TouchController.isEnabled()) {
overrides.inputConfiguration.enableTouchInput = true;
overrides.inputConfiguration.maxTouchPoints = 10;
}
// Enable mic
if (getPref(PrefKey.AUDIO_MIC_ON_PLAYING)) {
overrides.audioConfiguration = overrides.audioConfiguration || {};
overrides.audioConfiguration.enableMicrophone = true;
}
obj.clientStreamingConfigOverrides = JSON.stringify(overrides);
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async handle(request: RequestInfo | URL, init?: RequestInit) {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Server list
if (url.endsWith('/v2/login/user')) {
return XcloudInterceptor.#handleLogin(request, init);
} else if (url.endsWith('/sessions/cloud/play')) { // Get session
return XcloudInterceptor.#handlePlay(request, init);
} else if (url.includes('xboxlive.com') && url.includes('/waittime/')) {
return XcloudInterceptor.#handleWaitTime(request, init);
} else if (url.endsWith('/configuration')) {
return XcloudInterceptor.#handleConfiguration(request, init);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request as Request);
}
return NATIVE_FETCH(request, init);
}
}

View File

@ -0,0 +1,184 @@
import { RemotePlay } from "@/modules/remote-play";
import { TouchController } from "@/modules/touch-controller";
import { BxEvent } from "./bx-event";
import { InputType } from "./bx-exposed";
import { NATIVE_FETCH } from "./bx-flags";
import { STATES } from "./global";
import { getPref, PrefKey } from "./preferences";
import { patchIceCandidates } from "./network";
export class XhomeInterceptor {
static #consoleAddrs: {[index: string]: number} = {};
static async #handleLogin(request: Request) {
try {
const clone = (request as Request).clone();
const obj = await clone.json();
obj.offeringId = 'xhome';
request = new Request('https://xhome.gssv-play-prod.xboxlive.com/v2/login/user', {
method: 'POST',
body: JSON.stringify(obj),
headers: {
'Content-Type': 'application/json',
},
});
} catch (e) {
alert(e);
console.log(e);
}
return NATIVE_FETCH(request);
}
static async #handleConfiguration(request: Request | URL) {
const response = await NATIVE_FETCH(request);
const obj = await response.clone().json()
console.log(obj);
const serverDetails = obj.serverDetails;
if (serverDetails.ipAddress) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipAddress] = serverDetails.port;
}
if (serverDetails.ipV4Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port;
}
if (serverDetails.ipV6Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV6Address] = serverDetails.ipV6Port;
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) {
const response = await NATIVE_FETCH(request);
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'all') {
return response;
}
const obj = await response.clone().json() as any;
const xboxTitleId = JSON.parse(opts.body).titleIds[0];
STATES.currentStream.xboxTitleId = xboxTitleId;
const inputConfigs = obj[0];
let hasTouchSupport = inputConfigs.supportedTabs.length > 0;
if (!hasTouchSupport) {
const supportedInputTypes = inputConfigs.supportedInputTypes;
hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY);
}
if (hasTouchSupport) {
TouchController.disable();
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, {
data: null,
});
} else {
TouchController.enable();
TouchController.getCustomLayouts(xboxTitleId);
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleTitles(request: Request) {
const clone = request.clone();
const headers: {[index: string]: any} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
headers.authorization = `Bearer ${RemotePlay.XCLOUD_TOKEN}`;
const index = request.url.indexOf('.xboxlive.com');
request = new Request('https://wus.core.gssv-play-prod' + request.url.substring(index), {
method: clone.method,
body: await clone.text(),
headers: headers,
});
return NATIVE_FETCH(request);
}
static async #handlePlay(request: RequestInfo | URL) {
const clone = (request as Request).clone();
const body = await clone.json();
// body.settings.useIceConnection = true;
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async handle(request: Request) {
TouchController.disable();
const clone = request.clone();
const headers: {[index: string]: string} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
// Add xHome token to headers
headers.authorization = `Bearer ${RemotePlay.XHOME_TOKEN}`;
// Patch resolution
const deviceInfo = RemotePlay.BASE_DEVICE_INFO;
if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === '720p') {
deviceInfo.dev.os.name = 'android';
}
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);
const opts: {[index: string]: any} = {
method: clone.method,
headers: headers,
};
if (clone.method === 'POST') {
opts.body = await clone.text();
}
let newUrl = request.url;
if (!newUrl.includes('/servers/home')) {
const index = request.url.indexOf('.xboxlive.com');
newUrl = STATES.remotePlay.server + request.url.substring(index + 13);
}
request = new Request(newUrl, opts);
let url = (typeof request === 'string') ? request : request.url;
// Get console IP
if (url.includes('/configuration')) {
return XhomeInterceptor.#handleConfiguration(request);
} else if (url.endsWith('/sessions/home/play')) {
return XhomeInterceptor.#handlePlay(request);
} else if (url.includes('inputconfigs')) {
return XhomeInterceptor.#handleInputConfigs(request, opts);
} else if (url.includes('/login/user')) {
return XhomeInterceptor.#handleLogin(request);
} else if (url.endsWith('/titles')) {
return XhomeInterceptor.#handleTitles(request);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request, XhomeInterceptor.#consoleAddrs);
}
return await NATIVE_FETCH(request);
}
}