20 |
21 | #titleResetOnReloadOptions
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/assets/chrome/options.js:
--------------------------------------------------------------------------------
1 | const saveOptions = () => {
2 | const disableRenderer = document.getElementById('disableBdRenderer').checked;
3 | const disablePluginsOnReload = document.getElementById('disableBdPluginsOnReload').checked;
4 | const deleteRendererOnReload = document.getElementById('deleteBdRendererOnReload').checked;
5 |
6 | chrome.storage.sync.set(
7 | {
8 | disableBdRenderer: disableRenderer,
9 | disableBdPluginsOnReload: disablePluginsOnReload,
10 | deleteBdRendererOnReload: deleteRendererOnReload
11 | },
12 | () => {
13 |
14 | }
15 | );
16 | };
17 |
18 | const restoreOptions = () => {
19 | chrome.storage.sync.get(
20 | {
21 | disableBdRenderer: false,
22 | disableBdPluginsOnReload: false,
23 | deleteBdRendererOnReload: false
24 | },
25 | (options) => {
26 | document.getElementById('disableBdRenderer').checked = options.disableBdRenderer;
27 | document.getElementById('disableBdPluginsOnReload').checked = options.disableBdPluginsOnReload;
28 | document.getElementById('deleteBdRendererOnReload').checked = options.deleteBdRendererOnReload;
29 | }
30 | );
31 | };
32 |
33 | /**
34 | * Loads the i18n messages for the options page.
35 | * The i18n messages are loaded from the _locales folder.
36 | */
37 | const loadI18n = () => {
38 | let elements = document.querySelectorAll("[i18n]");
39 |
40 | elements.forEach(element => {
41 | const i18nElement = element.getAttribute("i18n");
42 | const i18nMessage = chrome.i18n.getMessage(i18nElement);
43 |
44 | if (i18nMessage) {
45 | element.innerText = i18nMessage;
46 | }
47 | });
48 | }
49 |
50 | /**
51 | * Operations to perform once options page has loaded.
52 | */
53 | const onContentLoaded = () => {
54 | loadI18n();
55 | restoreOptions();
56 | }
57 |
58 | const initialize = () => {
59 | document.addEventListener("DOMContentLoaded", onContentLoaded);
60 |
61 | document.getElementById("disableBdRenderer").addEventListener("change", saveOptions);
62 | document.getElementById("disableBdPluginsOnReload").addEventListener("change", saveOptions);
63 | document.getElementById("deleteBdRendererOnReload").addEventListener("change", saveOptions);
64 | }
65 |
66 | initialize();
67 |
--------------------------------------------------------------------------------
/assets/gh-readme/chrome-load-unpacked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tsukasa/BdBrowser/82bd51c6cb44e639f485208456b8c95c3dd02d2f/assets/gh-readme/chrome-load-unpacked.png
--------------------------------------------------------------------------------
/assets/scripts/VfsTool.ps1:
--------------------------------------------------------------------------------
1 | <#
2 | .SYNOPSIS
3 | A simple tool that can create and extract BdBrowser VFS backup files.
4 |
5 | .DESCRIPTION
6 | The VFS Tool script supports two operations: Create and Extract.
7 |
8 | When used in "Create" mode, you can specify the root directory of your
9 | BetterDiscord AppData path (i.e. C:\Users\Meow\AppData\Roaming\BetterDiscord),
10 | a destination path for the backup file and have the script create a serialized
11 | copy of your real filesystem that you can then upload into BdBrowser's virtual
12 | filesystem.
13 |
14 | So in short:
15 | Parameter -Path points to the BetterDiscord AppData path.
16 | Parameter -OutputPath points to the filename for the backup file to create.
17 |
18 | ---
19 |
20 | If you use the script in "Extract" mode, you do instead specify the path to
21 | the VFS backup file and set the root output path where the structure should
22 | be extracted to.
23 |
24 | So in short:
25 | Parameter -Path points to the VFS backup file to extract.
26 | Parameter -OutputPath points to the root directory where it should be extracted to.
27 |
28 | .PARAMETER Operation
29 | Defines in which mode this script operates.
30 |
31 | Can either be "Create" to create a new VFS backup file from the real filesystem or
32 | "Extract" to extract the contents of a VFS backup file.
33 |
34 | .PARAMETER Path
35 | When in "Create" mode:
36 | Absolute path to the root of BetterDiscord's AppData path.
37 |
38 | When in "Extract" mode:
39 | Relative or absolute path to the BdBrowser VFS Backup file.
40 |
41 | .PARAMETER OutputPath
42 | When in "Create" mode:
43 | Relative or absolute path where the BdBrowser VFS Backup file should be written to.
44 |
45 | When in "Extract" mode:
46 | Relative or absolute path to the directory where the backup file structure should
47 | be extracted to.
48 |
49 | .EXAMPLE
50 | .\VfsTool.ps1 -Operation Create -Path "C:\Users\Meow\AppData\Roaming\BetterDiscord" -OutputPath "C:\Temp\Backup.json"
51 |
52 | Creates a new VFS backup file in C:\Temp\Backup.json and serializes the data from the specified -Path.
53 |
54 | .EXAMPLE
55 | .\VfsTool.ps1 -Operation Extract -Path "C:\Temp\Backup.json" -OutputPath "C:\Temp\Extracted"
56 |
57 | Extracts the contents of the VFS backup file "C:\Temp\Backup.json" into "C:\Temp\Extracted".
58 |
59 | Please note that the target directory has to exist already!
60 | #>
61 |
62 | [CmdletBinding()]
63 | param (
64 | [Parameter(Mandatory)]
65 | [ValidateNotNullOrEmpty()]
66 | [ValidateSet("Extract", "Create")]
67 | [String] $Operation,
68 | [Parameter(Mandatory)]
69 | [ValidateNotNullOrEmpty()]
70 | [String] $Path,
71 | [Parameter(Mandatory)]
72 | [ValidateNotNullOrEmpty()]
73 | [String] $OutputPath
74 | )
75 |
76 | function Initialize() {
77 | switch($Operation) {
78 | "Create" { CreateBackup }
79 | "Extract" { ExtractFromBackup }
80 | }
81 | }
82 |
83 | function CreateBackup() {
84 | $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
85 | $pathContents = [Ordered] @{}
86 |
87 | # Create dummy file structure for expected root directories
88 |
89 | $pathContents["AppData"] = @{
90 | "atime" = $now
91 | "birthtime" = $now
92 | "ctime" = $now
93 | "fullName" = "AppData"
94 | "mtime" = $now
95 | "nodeType" = "dir"
96 | "pathName" = ""
97 | "size" = 0
98 | }
99 |
100 | $pathContents["AppData/BetterDiscord"] = @{
101 | "atime" = $now
102 | "birthtime" = $now
103 | "ctime" = $now
104 | "fullName" = "AppData/BetterDiscord"
105 | "mtime" = $now
106 | "nodeType" = "dir"
107 | "pathName" = "AppData"
108 | "size" = 0
109 | }
110 |
111 | # Now evaluate the contents of the given input path
112 |
113 | Push-Location -Path:$Path
114 |
115 | foreach($item in Get-ChildItem -Path:$Path -Recurse -Depth 99 -Exclude "emotes.asar") {
116 | $itemPathRelative = (Resolve-Path -Path $item -Relative).TrimStart('\', '.')
117 | $itemPathParent = ""
118 | $itemPath = (Join-Path -Path "AppData/BetterDiscord" -ChildPath $itemPathRelative).Replace('\', '/')
119 |
120 | $itemPathParent = [System.IO.Path]::GetDirectoryName($itemPath).TrimEnd('\').Replace('\', '/')
121 |
122 | if($item -is [System.IO.DirectoryInfo]) {
123 | $pathContents[$itemPath] = @{
124 | "atime" = $now
125 | "birthtime" = $now
126 | "ctime" = $now
127 | "fullName" = $itemPath
128 | "mtime" = $now
129 | "nodeType" = "dir"
130 | "pathName" = $itemPathParent
131 | "size" = 0
132 | }
133 | }
134 | else
135 | {
136 | $pathContents[$itemPath] = @{
137 | "atime" = $now
138 | "birthtime" = $now
139 | "contents" = [Convert]::ToBase64String([IO.File]::ReadAllBytes($item.FullName))
140 | "ctime" = $now
141 | "fullName" = $itemPath
142 | "mtime" = $now
143 | "nodeType" = "file"
144 | "pathName" = $itemPathParent
145 | "size" = $item.Length
146 | }
147 | }
148 | }
149 |
150 | Pop-Location
151 |
152 | $pathContents | Sort-Object -Property Name | ConvertTo-Json -Compress | Set-Content -Path:$OutputPath
153 | }
154 |
155 | function ExtractFromBackup() {
156 | $fileContent = Get-Content -Path:$Path -Raw | ConvertFrom-Json
157 |
158 | foreach($key in $fileContent.psobject.properties.name) {
159 | $obj = $fileContent.$key
160 |
161 | $targetPath = Join-Path -Path $OutputPath -ChildPath $obj.fullName
162 |
163 | if($obj.nodeType -eq "dir") {
164 | New-Item -Path $targetPath -ItemType Directory -Force | Out-Null
165 | }
166 |
167 | if($obj.nodeType -eq "file") {
168 | # Try to create the directory structure again.
169 | $folderPath = Join-Path -Path $OutputPath -ChildPath $obj.pathName
170 | New-Item -Path $folderPath -ItemType Directory -Force | Out-Null
171 |
172 | # Now write out the file.
173 | $fileBytes = [Convert]::FromBase64String($obj.contents)
174 | [IO.File]::WriteAllBytes($targetPath, $fileBytes)
175 | }
176 | }
177 | }
178 |
179 | Initialize
180 |
--------------------------------------------------------------------------------
/assets/spinner.example.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tsukasa/BdBrowser/82bd51c6cb44e639f485208456b8c95c3dd02d2f/assets/spinner.example.webm
--------------------------------------------------------------------------------
/backend/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "common": ["../common"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@bdbrowser/backend",
3 | "description": "BDBrowser Backend",
4 | "version": "0.0.0",
5 | "main": "src/index.js",
6 | "private": true,
7 | "scripts": {
8 | "build": "webpack --progress --color",
9 | "build-prod": "webpack --stats minimal --mode production",
10 | "lint": "eslint --ext .js src/"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/backend/src/index.js:
--------------------------------------------------------------------------------
1 | import {IPCEvents} from "common/constants";
2 | import DOM from "common/dom";
3 | import IPC from "common/ipc";
4 | import Logger from "common/logger";
5 | import LoadingScreen from "./modules/loadingscreen";
6 |
7 | /**
8 | * Initializes the "backend" side of BdBrowser.
9 | * Some parts need to fire early (document_start) in order to patch
10 | * objects in Discord's DOM while other parts are to be fired later
11 | * (document_idle) after the page has loaded.
12 | */
13 | function initialize() {
14 | const doOnDocumentComplete = () => {
15 | registerEvents();
16 |
17 | Logger.log("Backend", "Initializing modules.");
18 | injectFrontend(chrome.runtime.getURL("js/frontend.js"));
19 | };
20 |
21 | const documentCompleteCallback = () => {
22 | if (document.readyState !== "complete") {
23 | return;
24 | }
25 |
26 | document.removeEventListener("readystatechange", documentCompleteCallback);
27 | doOnDocumentComplete();
28 | };
29 |
30 | // Preload should fire as early as possible and is the reason for
31 | // running the backend during `document_start`.
32 | injectPreload();
33 |
34 | if (document.readyState === "complete") {
35 | doOnDocumentComplete();
36 | }
37 | else {
38 | document.addEventListener("readystatechange", documentCompleteCallback);
39 | }
40 |
41 | LoadingScreen.replaceLoadingAnimation();
42 | }
43 |
44 | /**
45 | * Injects the Frontend script into the page.
46 | * Should fire when the page is complete (document_idle).
47 | * @param {string} scriptUrl - Internal URL to the script
48 | */
49 | function injectFrontend(scriptUrl) {
50 | Logger.log("Backend", "Loading frontend script from:", scriptUrl);
51 | DOM.injectJS("BetterDiscordBrowser-frontend", scriptUrl, false);
52 | }
53 |
54 | /**
55 | * Injects the Preload script into the page.
56 | * Should fire as soon as possible (document_start).
57 | */
58 | function injectPreload() {
59 | Logger.log("Backend", "Injecting preload.js into document to prepare environment...");
60 |
61 | const scriptElement = document.createElement("script");
62 | scriptElement.src = chrome.runtime.getURL("js/preload.js");
63 |
64 | (document.head || document.documentElement).appendChild(scriptElement);
65 | }
66 |
67 | function registerEvents() {
68 | Logger.log("Backend", "Registering events.");
69 |
70 | const ipcMain = new IPC("backend");
71 |
72 | ipcMain.on(IPCEvents.GET_MANIFEST_INFO, (event) => {
73 | ipcMain.reply(event, chrome.runtime.getManifest());
74 | });
75 |
76 | ipcMain.on(IPCEvents.GET_RESOURCE_URL, (event, data) => {
77 | ipcMain.reply(event, chrome.runtime.getURL(data.url));
78 | });
79 |
80 | ipcMain.on(IPCEvents.GET_EXTENSION_OPTIONS, (event) => {
81 | chrome.storage.sync.get(
82 | {
83 | disableBdRenderer: false,
84 | disableBdPluginsOnReload: false,
85 | deleteBdRendererOnReload: false
86 | },
87 | (options) => {
88 | ipcMain.reply(event, options);
89 | }
90 | );
91 | });
92 |
93 | ipcMain.on(IPCEvents.SET_EXTENSION_OPTIONS, (event, data) => {
94 | chrome.storage.sync.set(
95 | data,
96 | () => {
97 | Logger.log("Backend", "Saved extension options:", data);
98 | });
99 | });
100 |
101 | ipcMain.on(IPCEvents.INJECT_CSS, (_, data) => {
102 | DOM.injectCSS(data.id, data.css);
103 | });
104 |
105 | ipcMain.on(IPCEvents.INJECT_THEME, (_, data) => {
106 | DOM.injectTheme(data.id, data.css);
107 | });
108 |
109 | ipcMain.on(IPCEvents.MAKE_REQUESTS, (event, data) => {
110 | // If the data is an object instead of a string, we probably
111 | // deal with a "request"-style request and have to re-order
112 | // the options.
113 | if (data.url && typeof(data.url) === "object") {
114 | // Deep clone data.url into the options and remove the url
115 | data.options = JSON.parse(JSON.stringify(data.url));
116 | data.options.url = undefined;
117 |
118 | if (data.url.url) {
119 | data.url = data.url.url;
120 | }
121 | }
122 |
123 | chrome.runtime.sendMessage(
124 | {
125 | operation: "fetch",
126 | parameters: {
127 | url: data.url,
128 | options: data.options
129 | }
130 | }, (response) => {
131 | try {
132 | if (response.error) {
133 | if (!data.url.startsWith(chrome.runtime.getURL(""))) {
134 | // eslint-disable-next-line no-console
135 | console.error("BdBrowser Backend MAKE_REQUESTS failed:", data.url, response.error);
136 | }
137 | ipcMain.reply(event, undefined);
138 | }
139 | else {
140 | // Response body comes in as a normal array, so requires
141 | // another round of casting into Uint8Array for the buffer.
142 | response.body = new Uint8Array(response.body).buffer;
143 | ipcMain.reply(event, response);
144 | }
145 | }
146 | catch (error) {
147 | Logger.error("Backend", "MAKE_REQUESTS failed:", error, data.url, response);
148 | ipcMain.reply(event, undefined);
149 | }
150 | }
151 | );
152 | });
153 | }
154 |
155 | initialize();
156 |
--------------------------------------------------------------------------------
/backend/src/modules/loadingscreen.js:
--------------------------------------------------------------------------------
1 | const LOADING_ANIMATION_SELECTOR = `video[data-testid="app-spinner"]`;
2 |
3 | export default class LoadingScreen {
4 | static #loadingObserver = new MutationObserver(() => {
5 | if (document.readyState === "complete") {
6 | this.#loadingObserver.disconnect();
7 | }
8 |
9 | const loadingAnimationElement = document.querySelector(LOADING_ANIMATION_SELECTOR);
10 | if (loadingAnimationElement) {
11 | this.#loadingObserver.disconnect();
12 |
13 | // Should be a WebM file with VP9 codec (400px x 400px) so the alpha channel gets preserved.
14 | const customAnimationSource = document.createElement("source");
15 | customAnimationSource.src = chrome.runtime.getURL("assets/spinner.webm");
16 | customAnimationSource.type = "video/webm";
17 |
18 | loadingAnimationElement.prepend(customAnimationSource);
19 | }
20 | });
21 |
22 | /**
23 | * Inserts the custom loading screen spinner animation from
24 | * `assets/spinner.webm` into the playlist.
25 | *
26 | * If the file cannot be found, the video player will automatically
27 | * choose one of the default Discord animations.
28 | */
29 | static replaceLoadingAnimation() {
30 | this.#loadingObserver.observe(document, {
31 | childList: true,
32 | subtree: true
33 | });
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/backend/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | module.exports = (env, argv) => ({
4 | mode: "development",
5 | target: "node",
6 | devtool: argv.mode === "production" ? undefined : "eval-source-map",
7 | entry: path.resolve(__dirname, "src", "index.js"),
8 | optimization: {
9 | minimize: false
10 | },
11 | output: {
12 | filename: "backend.js",
13 | path: path.resolve(__dirname, "..", "dist", "js")
14 | },
15 | resolve: {
16 | extensions: [".js", ".jsx"],
17 | modules: [
18 | path.resolve(__dirname, "src", "modules")
19 | ],
20 | alias: {
21 | common: path.join(__dirname, "..", "common"),
22 | assets: path.join(__dirname, "..", "assets")
23 | }
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/common/constants.js:
--------------------------------------------------------------------------------
1 | export const IPCEvents = {
2 | GET_MANIFEST_INFO: "bdbrowser-get-extension-manifest",
3 | GET_RESOURCE_URL: "bdbrowser-get-extension-resourceurl",
4 | GET_EXTENSION_OPTIONS: "bdbrowser-get-extension-options",
5 | HANDLE_PROTOCOL: "bd-handle-protocol",
6 | SET_EXTENSION_OPTIONS: "bdbrowser-set-extension-options",
7 | INJECT_CSS: "bdbrowser-inject-css",
8 | INJECT_THEME: "bdbrowser-inject-theme",
9 | MAKE_REQUESTS: "bdbrowser-make-requests"
10 | };
11 |
12 | export const FilePaths = {
13 | BD_ASAR_PATH: "AppData/BetterDiscord/data/betterdiscord.asar",
14 | BD_ASAR_VERSION_PATH: "AppData/BetterDiscord/data/bd-asar-version.txt",
15 | BD_CONFIG_PLUGINS_PATH: "AppData/BetterDiscord/data/&1/plugins.json",
16 | LOCAL_BD_ASAR_PATH: "bd/betterdiscord.asar",
17 | LOCAL_BD_RENDERER_PATH: "bd/renderer.js"
18 | };
19 |
20 | export const AppHostVersion = "1.0.9007";
21 |
22 | export default {
23 | IPCEvents,
24 | FilePaths,
25 | AppHostVersion
26 | };
27 |
--------------------------------------------------------------------------------
/common/dom.js:
--------------------------------------------------------------------------------
1 | export default class DOM {
2 | /**
3 | * @returns {HTMLElement}
4 | */
5 | static createElement(type, options = {}, ...children) {
6 | const node = document.createElement(type);
7 |
8 | Object.assign(node, options);
9 |
10 | for (const child of children) {
11 | node.append(child);
12 | }
13 |
14 | return node;
15 | }
16 |
17 | static injectTheme(id, css) {
18 | const [bdThemes] = document.getElementsByTagName("bd-themes");
19 |
20 | const style = this.createElement("style", {
21 | id: id,
22 | type: "text/css",
23 | innerHTML: css,
24 | });
25 |
26 | style.setAttribute("data-bd-native", "");
27 | bdThemes.append(style);
28 | }
29 |
30 | static injectCSS(id, css) {
31 | const style = this.createElement("style", {
32 | id: id,
33 | type: "text/css",
34 | innerHTML: css
35 | });
36 |
37 | this.headAppend(style);
38 | }
39 |
40 | static removeCSS(id) {
41 | const style = document.querySelector("style#" + id);
42 |
43 | if (style) {
44 | style.remove();
45 | }
46 | }
47 |
48 | static injectJS(id, src, silent = true) {
49 | const script = this.createElement("script", {
50 | id: id,
51 | type: "text/javascript",
52 | src: src
53 | });
54 |
55 | this.headAppend(script);
56 |
57 | if (silent) {
58 | script.addEventListener("load", () => {
59 | script.remove();
60 | }, {once: true});
61 | }
62 | }
63 | }
64 |
65 | const callback = () => {
66 | if (document.readyState !== "complete") {
67 | return;
68 | }
69 |
70 | document.removeEventListener("readystatechange", callback);
71 | DOM.headAppend = document.head.append.bind(document.head);
72 | };
73 |
74 | if (document.readyState === "complete") {
75 | DOM.headAppend = document.head.append.bind(document.head);
76 | }
77 | else {
78 | document.addEventListener("readystatechange", callback);
79 | }
80 |
--------------------------------------------------------------------------------
/common/ipc.js:
--------------------------------------------------------------------------------
1 | const IPC_REPLY_SUFFIX = "-reply";
2 |
3 | export default class IPC {
4 | constructor(context) {
5 | if (!context) {
6 | throw new Error("Context is required");
7 | }
8 |
9 | this.context = context;
10 | }
11 |
12 | createHash() {
13 | return Math.random().toString(36).substring(2, 10);
14 | }
15 |
16 | reply(message, data) {
17 | this.send(message.event.concat(IPC_REPLY_SUFFIX), data, void 0, message.hash);
18 | }
19 |
20 | on(event, listener, once = false) {
21 | const wrappedListener = (message) => {
22 | if (message.data.event !== event || message.data.context === this.context) {
23 | return;
24 | }
25 |
26 | const returnValue = listener(message.data, message.data.data);
27 |
28 | if (returnValue === true && once) {
29 | window.removeEventListener("message", wrappedListener);
30 | }
31 | };
32 |
33 | window.addEventListener("message", wrappedListener);
34 | }
35 |
36 | send(event, data, callback = null, hash) {
37 | if (!hash) {
38 | hash = this.createHash();
39 | }
40 |
41 | if (callback) {
42 | this.on(event.concat(IPC_REPLY_SUFFIX), message => {
43 | if (message.hash === hash) {
44 | callback(message.data);
45 | return true;
46 | }
47 |
48 | return false;
49 | }, true);
50 | }
51 |
52 | window.postMessage({
53 | source: "betterdiscord-browser".concat("-", this.context),
54 | event: event,
55 | context: this.context,
56 | hash: hash,
57 | data
58 | });
59 | }
60 |
61 | sendAwait(event, data, hash) {
62 | return new Promise((resolve) => {
63 | const callback = (d) => {
64 | resolve(d);
65 | };
66 |
67 | this.send(event, data, callback, hash);
68 | });
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/common/logger.js:
--------------------------------------------------------------------------------
1 | export default class Logger {
2 | static #parseType(type) {
3 | switch (type) {
4 | case "info":
5 | case "warn":
6 | case "error":
7 | return type;
8 | default:
9 | return "log";
10 | }
11 | }
12 |
13 | static #log(type, module, ...message) {
14 | type = this.#parseType(type);
15 | // eslint-disable-next-line no-console
16 | console[type](`%c[BDBrowser]%c %c[${module}]%c`, "color: #3E82E5; font-weight: 700;", "", "color: #396CB8", "", ...message);
17 | }
18 |
19 | static log(module, ...message) {
20 | this.#log("log", module, ...message);
21 | }
22 |
23 | static info(module, ...message) {
24 | this.#log("info", module, ...message);
25 | }
26 |
27 | static warn(module, ...message) {
28 | this.#log("warn", module, ...message);
29 | }
30 |
31 | static error(module, ...message) {
32 | this.#log("error", module, ...message);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "common": ["../common"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@bdbrowser/frontend",
3 | "description": "BDBrowser Frontend",
4 | "version": "0.0.0",
5 | "main": "src/index.js",
6 | "private": true,
7 | "scripts": {
8 | "build": "webpack --progress --color",
9 | "build-prod": "webpack --stats minimal --mode production",
10 | "lint": "eslint --ext .js src/"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/src/app_shims/discordnative.js:
--------------------------------------------------------------------------------
1 | import process from "app_shims/process";
2 | import discord_voice from "native_shims/discord_voice";
3 | import {AppHostVersion} from "common/constants";
4 |
5 | export const app = {
6 | getReleaseChannel() {
7 | if (window.location.href.includes("canary")) return "canary";
8 | if (window.location.href.includes("ptb")) return "ptb";
9 | return "stable";
10 | },
11 |
12 | getVersion() {
13 | return AppHostVersion;
14 | },
15 |
16 | async getPath(path) {
17 | switch (path) {
18 | case "appData":
19 | return process.env.APPDATA;
20 |
21 | default:
22 | throw new Error("Cannot find path: " + path);
23 | }
24 | },
25 |
26 | relaunch() {
27 | window.location.reload();
28 | }
29 | };
30 |
31 | export const nativeModules = {
32 | requireModule(module) {
33 | switch (module) {
34 | case "discord_voice":
35 | return discord_voice;
36 |
37 | default:
38 | throw new Error("Cannot find module: " + module);
39 | }
40 | }
41 | };
42 |
43 | export default {
44 | app,
45 | nativeModules
46 | };
47 |
--------------------------------------------------------------------------------
/frontend/src/app_shims/electron.js:
--------------------------------------------------------------------------------
1 | import ipcRenderer from "modules/ipcrenderer";
2 |
3 | ipcRenderer.initialize();
4 | export {ipcRenderer};
5 |
6 | export const remote = {
7 | app: {
8 | getAppPath: () => "ElectronAppPath"
9 | },
10 | getCurrentWindow: () => null,
11 | getCurrentWebContents: () => ({
12 | on: () => {}
13 | })
14 | };
15 |
16 | export const shell = {
17 | openItem: () => {},
18 | openExternal: () => {}
19 | };
20 |
21 | export const clipboard = {
22 | write: (data) => {
23 | if (typeof(data) != "object") return;
24 | if (data.text) {
25 | clipboard.writeText(data.text);
26 | }
27 | },
28 | writeText: text => navigator.clipboard.writeText(text),
29 | };
30 |
31 | export default {
32 | clipboard,
33 | ipcRenderer,
34 | remote,
35 | shell
36 | };
37 |
--------------------------------------------------------------------------------
/frontend/src/app_shims/process.js:
--------------------------------------------------------------------------------
1 | export default {
2 | platform: "win32",
3 | env: {
4 | APPDATA: "AppData",
5 | DISCORD_APP_PATH: "AppData/Discord/AppPath",
6 | DISCORD_USER_DATA: "AppData/Discord/UserData",
7 | BETTERDISCORD_DATA_PATH: "AppData/BetterDiscord"
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import fs from "node_shims/fs";
2 | import startup from "modules/startup";
3 | import RuntimeOptions from "modules/runtimeoptions";
4 |
5 | import "patches";
6 |
7 | (async () => {
8 | startup.prepareWindow();
9 |
10 | await RuntimeOptions.initializeOptions();
11 |
12 | if (!await fs.openDatabase()) {
13 | throw new Error("BdBrowser Error: IndexedDB VFS database connection could not be established!");
14 | }
15 |
16 | if (!await fs.initializeVfs()) {
17 | throw new Error("BdBrowser Error: IndexedDB VFS could not be initialized!");
18 | }
19 |
20 | if (!await startup.checkAndDownloadBetterDiscordAsar()) {
21 | throw new Error("BdBrowser Error: Downloading betterdiscord.asar or writing into VFS failed!");
22 | }
23 |
24 | if (!await startup.loadBetterDiscord()) {
25 | throw new Error("BdBrowser Error: Cannot load BetterDiscord renderer for injection!");
26 | }
27 | })();
28 |
--------------------------------------------------------------------------------
/frontend/src/modules/asar.js:
--------------------------------------------------------------------------------
1 | const headerSizeIndex = 12,
2 | headerOffset = 16,
3 | uInt32Size = 4,
4 | textDecoder = new TextDecoder("utf-8");
5 |
6 | // Essentially just ripped from the chromium-pickle-js source, thanks for
7 | // doing my math homework.
8 | const alignInt = (i, alignment) =>
9 | i + (alignment - (i % alignment)) % alignment;
10 |
11 | /**
12 | *
13 | * @param {ArrayBuffer} archive Asar archive to open
14 | * @returns {ArchiveData}
15 | */
16 | const openAsar = archive => {
17 | if (archive.length > Number.MAX_SAFE_INTEGER) {
18 | throw new Error("Asar archive too large.");
19 | }
20 |
21 | const headerSize = new DataView(archive).getUint32(headerSizeIndex, true),
22 | // Pickle wants to align the headers so that the payload length is
23 | // always a multiple of 4. This means you'll get "padding" bytes
24 | // after the header if you don't round up the stored value.
25 | //
26 | // IMO why not just store the aligned int and have us trim the json,
27 | // but it's whatever.
28 | headerEnd = headerOffset + headerSize,
29 | filesOffset = alignInt(headerEnd, uInt32Size),
30 | rawHeader = archive.slice(headerOffset, headerEnd),
31 | buffer = archive.slice(filesOffset);
32 |
33 | /**
34 | * @typedef {Object} ArchiveData
35 | * @property {Object} header - The asar file's manifest, containing the pointers to each index's files in the buffer
36 | * @property {ArrayBuffer} buffer - The contents of the archive, concatenated together.
37 | */
38 | return {
39 | header: JSON.parse(textDecoder.decode(rawHeader)),
40 | buffer
41 | };
42 | };
43 |
44 | const crawlHeader = function self(files, dirname) {
45 | const prefix = itemName =>
46 | (dirname ? dirname + "/" : "") + itemName;
47 |
48 | let children = [];
49 |
50 | for (const filename in files) {
51 | const extraFiles = files[filename].files;
52 |
53 | if (extraFiles) {
54 | const extra = self(extraFiles, filename);
55 |
56 | children = children.concat(extra);
57 | }
58 |
59 | children.push(filename);
60 | }
61 |
62 | return children.map(prefix);
63 | };
64 |
65 | /**
66 | * These paths must be absolute and posix-style, without a leading forward slash.
67 | * @typedef {String} ArchivePath
68 | */
69 |
70 | /**
71 | * An Asar archive
72 | * @class
73 | * @param {ArrayBuffer} archive The archive to open
74 | */
75 | class Asar {
76 | constructor(archive) {
77 | const {header, buffer} = openAsar(archive);
78 |
79 | this.header = header;
80 | this.buffer = buffer;
81 | this.contents = crawlHeader(header);
82 | }
83 |
84 | /**
85 | * Retrieves information on a directory or file from the archive's header
86 | * @param {ArchivePath} path The path to the dirent
87 | * @returns {Object}
88 | */
89 | find(path) {
90 | const navigate = (currentItem, navigateTo) => {
91 | if (currentItem.files) {
92 | const nextItem = currentItem.files[navigateTo];
93 |
94 | if (!nextItem) {
95 | if (path == "/") { // This breaks it lol
96 | return this.header;
97 | }
98 |
99 | throw new PathError(path, `${navigateTo} could not be found.`);
100 | }
101 |
102 | return nextItem;
103 | }
104 |
105 | throw new PathError(path, `${navigateTo} is not a directory.`);
106 | };
107 |
108 | return path
109 | .split("/")
110 | .reduce(navigate, this.header);
111 | }
112 |
113 | /**
114 | * Open a file in the archive
115 | * @param {ArchivePath} path The path to the file
116 | * @returns {ArrayBuffer} The file's contents
117 | */
118 | get(path) {
119 | const {offset, size} = this.find(path),
120 | offsetInt = parseInt(offset);
121 |
122 | return this.buffer.slice(offsetInt, offsetInt + size);
123 | }
124 | }
125 |
126 | class PathError extends Error {
127 | constructor(path, message) {
128 | super(`Invalid path "${path}": ${message}`);
129 |
130 | this.name = "PathError";
131 | }
132 | }
133 |
134 | export {Asar as default};
135 |
--------------------------------------------------------------------------------
/frontend/src/modules/bdasarupdater.js:
--------------------------------------------------------------------------------
1 | import Logger from "common/logger";
2 | import fs from "node_shims/fs";
3 | import request from "node_shims/request";
4 | import {FilePaths} from "common/constants";
5 |
6 | const USER_AGENT = "BdBrowser Updater";
7 | const LOGGER_SECTION = "AsarUpdater";
8 |
9 | export default class BdAsarUpdater {
10 | /**
11 | * Gets the version of BetterDiscord's asar according to the version file in the VFS.
12 | * @returns {string} - Version number or `0.0.0` if no value is set yet.
13 | */
14 | static getVfsBetterDiscordAsarVersion() {
15 | if (!fs.existsSync(FilePaths.BD_ASAR_VERSION_PATH)) {
16 | return "0.0.0";
17 | }
18 |
19 | return fs.readFileSync(FilePaths.BD_ASAR_VERSION_PATH).toString();
20 | }
21 |
22 | /**
23 | * Sets the version of BetterDiscord's asar in the version file within the VFS.
24 | * @param {string} versionString
25 | */
26 | static setVfsBetterDiscordAsarVersion(versionString) {
27 | fs.writeFileSync(FilePaths.BD_ASAR_VERSION_PATH, versionString);
28 | }
29 |
30 | /**
31 | * Returns whether a BetterDiscord asar exists in the VFS.
32 | * @returns {boolean}
33 | */
34 | static get hasBetterDiscordAsarInVfs() {
35 | return fs.existsSync(FilePaths.BD_ASAR_PATH);
36 | }
37 |
38 | /**
39 | * Returns a Buffer containing the contents of the asar file.
40 | * If the file is not present in the VFS, a ENOENT exception is thrown.
41 | * @returns {*|Buffer}
42 | */
43 | static get asarFile() {
44 | if (this.hasBetterDiscordAsarInVfs) return fs.readFileSync(FilePaths.BD_ASAR_PATH);
45 | return fs.statSync(FilePaths.BD_ASAR_PATH);
46 | }
47 |
48 | /**
49 | * Checks BetterDiscord's GitHub releases for the latest version and returns
50 | * the update information to the caller.
51 | * @returns {Promise<{hasUpdate: boolean, data: any, remoteVersion: *}>}
52 | */
53 | static async getCurrentBdVersionInfo() {
54 | Logger.log(LOGGER_SECTION, "Checking for latest BetterDiscord version...");
55 |
56 | const resp = await fetch("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest", {
57 | method: "GET",
58 | headers: {
59 | "Accept": "application/json",
60 | "Content-Type": "application/json",
61 | "User-Agent": USER_AGENT
62 | }
63 | });
64 |
65 | const data = await resp.json();
66 | const remoteVersion = data.tag_name.startsWith("v") ? data.tag_name.slice(1) : data.tag_name;
67 | const hasUpdate = remoteVersion > this.getVfsBetterDiscordAsarVersion();
68 |
69 | Logger.log(LOGGER_SECTION, `Latest stable BetterDiscord version is ${remoteVersion}.`);
70 |
71 | return {
72 | data,
73 | remoteVersion,
74 | hasUpdate
75 | };
76 | }
77 |
78 | /**
79 | * Downloads the betterdiscord.asar specified in updateInfo and saves the file into the VFS.
80 | * @param updateInfo
81 | * @param remoteVersion
82 | * @returns {Promise