Slacked: Part 1

Posted on 26th April 2023 at 20:13

My journey into writing a Vencord like client mod for Slack

For a while now, I’ve been using Vencord - a Discord client mod.

It allows you to write custom plugins and load custom themes, that sort of stuff.

In my case, I have a nice Catppuccin Mocha theme and several plugins such as a deleted message logger, ability to see channels not available to me and a plugin that adds a little oneko that follows my cursor.

Little Oneko Vibin

Little Oneko Vibin

But at work, we use Slack, Discord for super profressional serious business people. Which from my research has no such client mod.

There was some interesting stuff, there’s this repo which is very old but shows a POC. There’s also SlackMod, which uses a python library electron_inject to inject javascript into closed source electron apps, a neat idea.

But Vencord’s method of code injection into Discord was interesting to me.

How does Vencord work?

All electron apps have a resources folder, inside which you’ll find an app.asar. This file contains the apps source code packaged into a single file. There’s nothing special about these files, they’re not encrypted or obfuscated, you can extract the content inside. Electron apps will also load an app.asar directory if extracted, it doesn’t need to be bundled into the archive.

What Vencord does is make a copy of the original asar and replaces the main app.asar with it’s own script. All this script does is load their injector script which is stored elsewhere.

require('/home/emily/.config/Vencord/dist/patcher.js');

But the patcher is where the magic happens.

Vencord’s patcher

The first step to understanding this was, how does it actually load Discord? None of the fancy plugins/modding matters if I don’t even know how it eventually opens Discord from its patcher.

I’ve simplified some parts here for clarity.

import { app } from 'electron';

const injectorPath = require.main!.filename;

// The original app.asar
const asarPath = join(dirname(injectorPath), '..', '_app.asar');

const discordPkg = require(join(asarPath, 'package.json'));
require.main!.filename = join(asarPath, discordPkg.main);

app.setAppPath(asarPath);

// some code here we'll get to...

console.log('[Vencord] Loading original Discord app.asar');
require(require.main!.filename);

Interesting! So all it’s doing is replacing require.main!.filename with the path to Discord’s actual package.json and loading that at the end! But, does this work for Slack?

Uhhh kinda?

It started! Which is a good sign, the code runs but it seems to exit for some reason.

To debug this, I had to delve into Slack’s minified source code :D

Inside that mess I searched for all process.exit(0) as the exit code was 0, until I found the culprit!

b.app.requestSingleInstanceLock() || oe.app.exit(0), process.exit(0);

Inside of main.bundle.js requestSingleInstanceLock appears to fail, I suppose this is because technically I am running two instances? My script and then Slack’s actual code? So, let’s replace this part with sed.

sed 's/||oe.app.exit(0),process.exit(0)//g' -i main.bundle.js

It's alive!

This skeleton code now boots into slack via our script! This is where the fun starts.

import { dirname, join } from 'path';
import { app } from 'electron';

const injectorPath = require.main!.filename;

const asarPath = join(dirname(injectorPath), '..', '_app.asar');

const slackPackage = require(join(asarPath, 'package.json'));
require.main!.filename = join(asarPath, slackPackage.main);

// @ts-ignore
app.setAppPath(asarPath);

require(require.main!.filename);

Opening Chrome Dev Tools on Startup

Let’s start with something simple, how can we trigger the dev tools on startup?

Looking through the rest of Vencord’s patcher, I see that it creates a BrowserWindow class that extends electron.BrowserWindow. It only provides an implementation for the constructor and a lot of that isn’t relevant for us right now. The interesting part is this initIpc(this) function, but we’ll come back to that.

Let’s create our BrowserWindow class like so.

// @ts-ignore
app.setAppPath(asarPath);

const initIpc = (window: electron.BrowserWindow) => {};

class BrowserWindow extends electron.BrowserWindow {
	constructor(options: BrowserWindowConstructorOptions) {
		super(options);
		initIpc(this);
	}
}

require(require.main!.filename);

After which we copy over all of the methods and properties on the original electron.BrowserWindow to our new window class.

Object.assign(BrowserWindow, electron.BrowserWindow);

And then we also have to replace electrons BrowserWindow exports.

const electronPath = require.resolve('electron');
delete require.cache[electronPath]!.exports;
require.cache[electronPath]!.exports = {
	...electron,
	BrowserWindow
};

That’s it! Suprisingly simple when viewed from our perspective, but I would’ve never figured this out without some serious time and research, so thanks V.

Heading back to that initIpc function, we passed along our custom BrowserWindow instance to that function. With electron you can programmatically open devtools from that BrowserWindow instance, let’s try it.

const initIpc = (window: electron.BrowserWindow) => {
	window.webContents.openDevTools();
};

Annnnnnnnd…

There he is! Mr. Devtools

Wowie, we did it! We can now execute custom code automatically within a Slack desktop client :D

But how does Vencord’s text replacement plugin system work? Can we implement it with our mod? What about loading custom themes? That’s for part 2.

slack

client mod

vencord