Skip to content

Instantly share code, notes, and snippets.

@aquelemiguel
Last active December 22, 2023 12:30
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aquelemiguel/170eadf2883d783b24236d249ab28fb9 to your computer and use it in GitHub Desktop.
Save aquelemiguel/170eadf2883d783b24236d249ab28fb9 to your computer and use it in GitHub Desktop.
πŸ” Reverse engineering a search bar into PlayStation Plus

Reverse engineering a search bar into PlayStation Plus


πŸ†• @MaciekBaron reimagined this gist in https://github.com/MaciekBaron/PSPLUSTilesTheme with a cleaner and more efficient implementation. Give it a go!


The reimagined PlayStation Plus service is finally here!

However, PC users are stuck with a reskin of the old, not-so-great PlayStation Now launcher. It's built with Electron, so it's bound to eat up your RAM. But worse of all, even now as the library clocks at 700+ games, Sony still refuses to add a search function.

Now, Sony took 13 years to let users change their PSN Online ID so I can understand they like to cautiously take their time really perfecting these features. But this time I'm taking matters into my own hands and rest assured I've already cleared my agenda for the next two decades.

image

Two easy steps

1. Unpacking app.asar

Let's get started. Open a terminal as an administrator. Then navigate to the resources folder in launcher installation directory. This is my path to the folder (yours might differ):

$ cd C:\Program Files (x86)\PlayStationPlus\agl\resources

There should be an app.asar file in this folder. asar is an archive format for compressing your Electron app. We need to unpack it to have access to the project's files.

$ npx asar extract app.asar app

This generates an app folder in the same directory. The launcher will either look for the app.asar archive or this new unpacked folder. Let's rename the old app.asar to app.asar.bak to make the launcher prefer our new folder.

2. Editing preload_context_isolation.js

Now, the launcher should react to changes in the app folder! Next, we'll be injecting our code into app/html/preload_context_isolation.js. Open this file in a nice text editor.

Identify this line near the top of the file:

var GKP = function () {

We'll be writing all our code underneath it. Fun fact, GKP stands for Gaikai Player - the company Sony acquired in 2012 to kickstart their cloud gaming sector.

Directly underneath it, we'll be pasting this code:

const PSPP_WaitForCatalogListLoad = () => {
  const observer = new MutationObserver(() => {
    const catalogList = document.querySelector(".catalog-list");

    if (catalogList) {
      observer.disconnect();
      catalogList.prepend(PSPP_CreateInputElement());
      catalogList.prepend(PSPP_CreateDatalistElement());
    }
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
  });
};

const PSPP_CreateInputElement = () => {
  let inputField = document.createElement("input");
  inputField.setAttribute("class", "pspp_custom_input");
  inputField.setAttribute("list", "pspp_titles");

  inputField.style.cssText = `
        color: white;
        height: 2rem;
        position: relative;
        z-index: 1;
        background: transparent;
        margin: 0.5rem 2rem;
        width: 70%;
        padding-left: 0.5rem;
      `;

  inputField = PSPP_UpdateInputElement(inputField, false, 0);
  return inputField;
};

const PSPP_UpdateInputElement = (element, isEnabled, progress) => {
  isEnabled ?
    element.removeAttribute("disabled") :
    element.setAttribute("disabled", "");

  let placeholder = isEnabled ?
    "Search for a title..." :
    `Fetching titles... This might take a while...`;

  if (!isEnabled && progress > 0) {
    placeholder += ` (${progress}%)`;
  }

  element.setAttribute("placeholder", placeholder);
  return element;
};

const PSPP_RegisterInputChange = (gameList) => {
  document
    .querySelector(".pspp_custom_input")
    .addEventListener("change", (event) => {
      const found = gameList.find(
        (game) => game.title === event.target.value
      );

      if (found) {
        found.element.dispatchEvent(
          new MouseEvent("click", {
            view: window,
            bubbles: true,
            cancelable: false,
          })
        );
      }
    });
};

const PSPP_CreateDatalistElement = (titles) => {
  const datalist = document.createElement("datalist");
  datalist.setAttribute("class", "pspp_custom_datalist");
  datalist.setAttribute("id", "pspp_titles");
  return datalist;
};

const PSPP_UpdateDatalistElement = (element, titles) => {
  titles.map((title) => {
    const option = document.createElement("option");
    option.setAttribute("value", title);
    element.appendChild(option);
  });

  return element;
};

document.addEventListener("DOMContentLoaded", async () => {
  PSPP_WaitForCatalogListLoad();

  // wait for game tiles to appear in the DOM
  const observer = new MutationObserver(async (mutations) => {
    const selector = document.querySelectorAll(".games-list-tile");
    if (selector.length > 0) {
      observer.disconnect();

      let gameList = [...selector].map((element) => {
        const url = element.children[0].src;
        return {
          element,
          url: url.slice(0, url.lastIndexOf("/"))
        };
      });

      // divide into batches of 25 for performance reasons
      let batches = [];
      for (let i = 0; i < gameList.length; i += 25) {
        batches.push(gameList.slice(i, i + 25));
      }

      const assignTitleToPlusGame = (url, title) => {
        const idx = gameList.findIndex((plusGame) => plusGame.url === url);

        if (idx !== -1) {
          gameList[idx].title = title;
        }
      };

      const requestAllBatches = async (batches) => {
        for (let i = 0; i < batches.length; i++) {
          await requestBatch(batches[i], i + 1);
        }
      };

      const requestBatch = async (batch, idx) => {
        const promises = batch.map((plusGame) =>
          fetch(plusGame.url)
          .then((res) => res.json())
          .then((metadata) =>
            assignTitleToPlusGame(plusGame.url, metadata.name)
          )
        );

        const customInput = document.querySelector(".pspp_custom_input");

        PSPP_UpdateInputElement(
          customInput,
          false,
          Math.round((idx / batches.length) * 100)
        );

        return await Promise.all(promises);
      };

      await requestAllBatches(batches);

      gameList = gameList.filter((game) => game.title);

      const customInput = document.querySelector(".pspp_custom_input");
      const customDatalist = document.querySelector(".pspp_custom_datalist");

      PSPP_UpdateInputElement(customInput, true, 0);

      const titles = gameList.map((game) => game.title);
      PSPP_UpdateDatalistElement(customDatalist, titles);

      PSPP_RegisterInputChange(gameList);
    }
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
  });
});

Save the file (you probably need admin permissions here as well) and you're done!

Reverse engineering

Bear in mind that reverse engineering an app like this greatly limits the approaches you have at your disposal. In this case, I felt like I needed to improvise based on what I could gather from the DOM. Thus the adopted solution is not particularly efficient, but is still one I'm happy with!

Upon launch you should now see a fresh new section underneath the header. It'll fetch the game names for a while.

image

In the background, the launcher is doing the following:

  1. Getting a list of all the little square tile image HTML elements.
  2. For each element, grabbing the URL of the image. The URL contains the title's content ID (e.g. EP9000-CUSA20177_00-MARVELSSMMORALES).
  3. With the content ID, it requests the full metadata for a title. We're only interested in the name here. These requests are done in batches not to overload the network.
  4. After fetching all the game titles, the autocomplete input field is populated.
  5. Then, whenever a user inputs a title, it's matched to its corresponding tile HTML element. A mouse click is simulated, thus navigating to the game's page.

image

References

@MaciekBaron
Copy link

Thanks aquelemiguel! This inspired me to create my own theme for the application, which you can find here:
https://github.com/MaciekBaron/PSPLUSTilesTheme

I did the search differently and send fewer requests (the game names are available in the list entries).

@aquelemiguel
Copy link
Author

@MaciekBaron I love your implementation! I confess the tiles were what I had in mind initially and didn't really like request spam (they were so many I had to send them in batches). I'll add your repo to the gist! πŸ’―

@MaciekBaron
Copy link

Thanks @aquelemiguel ! I'm glad you like the approach. Your gist definitely helped me to kickstart the whole process. Cheers!

@Ellosaur
Copy link

Ellosaur commented May 5, 2023

Great work @aquelemiguel loved the explanation and "fun fact" too!

@QuagmireSW
Copy link

great work but here is an improved code var GKP = function () { const PSPP_CreateInputElement = () => {
const inputField = document.createElement("input");
inputField.className = "pspp_custom_input";
inputField.setAttribute("list", "pspp_titles");
inputField.style.cssText = color: white; height: 2rem; position: relative; z-index: 1; background: transparent; margin: 0.5rem 2rem; width: 70%; padding-left: 0.5rem;;
return inputField;
};

const PSPP_UpdateInputElement = (element, isEnabled, progress) => {
element.disabled = !isEnabled;
let placeholder = isEnabled ? "Search for a title..." : "Fetching titles... This might take a while...";
if (!isEnabled && progress > 0) {
placeholder += (${progress}%);
}
element.placeholder = placeholder;
return element;
};

const PSPP_RegisterInputChange = (gameList) => {
const customInput = document.querySelector(".pspp_custom_input");
customInput.addEventListener("change", (event) => {
const found = gameList.find((game) => game.title === event.target.value);
if (found) {
found.element.dispatchEvent(new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: false,
}));
}
});
};

const PSPP_CreateDatalistElement = () => {
const datalist = document.createElement("datalist");
datalist.className = "pspp_custom_datalist";
datalist.id = "pspp_titles";
return datalist;
};

const PSPP_UpdateDatalistElement = (element, titles) => {
element.innerHTML = titles.map((title) => <option value="${title}"></option>).join("");
return element;
};

const PSPP_ProcessGameList = async (gameList) => {
const batches = [];
for (let i = 0; i < gameList.length; i += 25) {
batches.push(gameList.slice(i, i + 25));
}

const assignTitleToPlusGame = (url, title) => {
const idx = gameList.findIndex((plusGame) => plusGame.url === url);
if (idx !== -1) {
gameList[idx].title = title;
}
};

const requestBatch = async (batch, idx) => {
const promises = batch.map((plusGame) => {
return fetch(plusGame.url)
.then((res) => res.json())
.then((metadata) => assignTitleToPlusGame(plusGame.url, metadata.name));
});

const customInput = document.querySelector(".pspp_custom_input");
PSPP_UpdateInputElement(customInput, false, Math.round((idx / batches.length) * 100));

await Promise.all(promises);

};

for (let i = 0; i < batches.length; i++) {
await requestBatch(batches[i], i + 1);
}

gameList = gameList.filter((game) => game.title);

const customInput = document.querySelector(".pspp_custom_input");
const customDatalist = document.querySelector(".pspp_custom_datalist");

PSPP_UpdateInputElement(customInput, true, 0);

const titles = gameList.map((game) => game.title);
PSPP_UpdateDatalistElement(customDatalist, titles);

PSPP_RegisterInputChange(gameList);
};

const PSPP_WaitForCatalogListLoad = async () => {
const observer = new MutationObserver((mutations) => {
const catalogList = document.querySelector(".catalog-list");
if (catalogList) {
observer.disconnect();
const inputElement = PSPP_CreateInputElement();
const datalistElement = PSPP_CreateDatalistElement();
catalogList.prepend(inputElement, datalistElement);
PSPP_ProcessGameList([]);
}
});

observer.observe(document.body, {
childList: true,
subtree: true,
});
};

document.addEventListener("DOMContentLoaded", async () => {
PSPP_WaitForCatalogListLoad();

const observer = new MutationObserver((mutations) => {
const selector = document.querySelectorAll(".games-list-tile");
if (selector.length > 0) {
observer.disconnect();
const gameList = Array.from(selector).map((element) => {
const url = element.children[0].src;
return {
element,
url: url.slice(0, url.lastIndexOf("/")),
};
});
PSPP_ProcessGameList(gameList);
}
});

observer.observe(document.body, {
childList: true,
subtree: true,
});
});

@joaofbravo
Copy link

joaofbravo commented Dec 11, 2023

great work but here is an improved code var GKP = function () { const PSPP_CreateInputElement = () => { const inputField = document.createElement("input"); inputField.className = "pspp_custom_input"; inputField.setAttribute("list", "pspp_titles"); inputField.style.cssText = color: white; height: 2rem; position: relative; z-index: 1; background: transparent; margin: 0.5rem 2rem; width: 70%; padding-left: 0.5rem;; return inputField; };

const PSPP_UpdateInputElement = (element, isEnabled, progress) => { element.disabled = !isEnabled; let placeholder = isEnabled ? "Search for a title..." : "Fetching titles... This might take a while..."; if (!isEnabled && progress > 0) { placeholder += (${progress}%); } element.placeholder = placeholder; return element; };

const PSPP_RegisterInputChange = (gameList) => { const customInput = document.querySelector(".pspp_custom_input"); customInput.addEventListener("change", (event) => { const found = gameList.find((game) => game.title === event.target.value); if (found) { found.element.dispatchEvent(new MouseEvent("click", { view: window, bubbles: true, cancelable: false, })); } }); };

const PSPP_CreateDatalistElement = () => { const datalist = document.createElement("datalist"); datalist.className = "pspp_custom_datalist"; datalist.id = "pspp_titles"; return datalist; };

const PSPP_UpdateDatalistElement = (element, titles) => { element.innerHTML = titles.map((title) => <option value="${title}"></option>).join(""); return element; };

const PSPP_ProcessGameList = async (gameList) => { const batches = []; for (let i = 0; i < gameList.length; i += 25) { batches.push(gameList.slice(i, i + 25)); }

const assignTitleToPlusGame = (url, title) => { const idx = gameList.findIndex((plusGame) => plusGame.url === url); if (idx !== -1) { gameList[idx].title = title; } };

const requestBatch = async (batch, idx) => { const promises = batch.map((plusGame) => { return fetch(plusGame.url) .then((res) => res.json()) .then((metadata) => assignTitleToPlusGame(plusGame.url, metadata.name)); });

const customInput = document.querySelector(".pspp_custom_input");
PSPP_UpdateInputElement(customInput, false, Math.round((idx / batches.length) * 100));

await Promise.all(promises);

};

for (let i = 0; i < batches.length; i++) { await requestBatch(batches[i], i + 1); }

gameList = gameList.filter((game) => game.title);

const customInput = document.querySelector(".pspp_custom_input"); const customDatalist = document.querySelector(".pspp_custom_datalist");

PSPP_UpdateInputElement(customInput, true, 0);

const titles = gameList.map((game) => game.title); PSPP_UpdateDatalistElement(customDatalist, titles);

PSPP_RegisterInputChange(gameList); };

const PSPP_WaitForCatalogListLoad = async () => { const observer = new MutationObserver((mutations) => { const catalogList = document.querySelector(".catalog-list"); if (catalogList) { observer.disconnect(); const inputElement = PSPP_CreateInputElement(); const datalistElement = PSPP_CreateDatalistElement(); catalogList.prepend(inputElement, datalistElement); PSPP_ProcessGameList([]); } });

observer.observe(document.body, { childList: true, subtree: true, }); };

document.addEventListener("DOMContentLoaded", async () => { PSPP_WaitForCatalogListLoad();

const observer = new MutationObserver((mutations) => { const selector = document.querySelectorAll(".games-list-tile"); if (selector.length > 0) { observer.disconnect(); const gameList = Array.from(selector).map((element) => { const url = element.children[0].src; return { element, url: url.slice(0, url.lastIndexOf("/")), }; }); PSPP_ProcessGameList(gameList); } });

observer.observe(document.body, { childList: true, subtree: true, }); });

What is the improvement here? Should we just directly replace that code?

Edit: This code does not work, crashes the app.

@andrewgosselin
Copy link

This is awesome, nice work!
Never thought to decompile the electron application.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment