this repo has no description

Initial commit

Co-authored-by: Cynthia Foxwell <gamers@riseup.net>
Co-authored-by: adryd <me@adryd.com>

+2
.gitignore
···
+
node_modules/
+
dist/
+6
.prettierrc
···
+
{
+
"printWidth": 80,
+
"trailingComma": "none",
+
"tabWidth": 2,
+
"singleQuote": false
+
}
+17
README.md
···
+
<h3 align="center">
+
<img src="./img/wordmark.png" alt="moonlight" />
+
+
<a href="https://discord.gg/FdZBTFCP6F">Discord server</a>
+
\- <a href="https://github.com/moonlight-mod/moonlight">GitHub</a>
+
\- <a href="https://moonlight-mod.github.io/">Docs</a>
+
+
<hr />
+
</h3>
+
+
**moonlight** is yet another Discord client mod, focused on providing a decent user and developer experience.
+
+
moonlight is heavily inspired by hh3 (a private client mod) and the projects before it that it is inspired by, namely EndPwn. All core code is original or used with permission from their respective authors where not copyleft.
+
+
**_This is an experimental passion project._** moonlight was not created out of malicious intent nor intended to seriously compete with other mods. Anything and everything is subject to change.
+
+
moonlight is licensed under the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) (`AGPL-3.0-or-later`). See [the documentation](https://moonlight-mod.github.io/) for more information.
+137
build.mjs
···
+
import * as esbuild from "esbuild";
+
import copyStaticFiles from "esbuild-copy-static-files";
+
+
import path from "path";
+
import fs from "fs";
+
+
const config = {
+
injector: "packages/injector/src/index.ts",
+
"node-preload": "packages/node-preload/src/index.ts",
+
"web-preload": "packages/web-preload/src/index.ts"
+
};
+
+
const prod = process.env.NODE_ENV === "production";
+
const watch = process.argv.includes("--watch");
+
+
const external = [
+
"electron",
+
"fs",
+
"path",
+
"module",
+
"events",
+
"original-fs", // wtf asar?
+
+
// Silence an esbuild warning
+
"./node-preload.js"
+
];
+
+
async function build(name, entry) {
+
const outfile = path.join("./dist", name + ".js");
+
+
const dropLabels = [];
+
if (name !== "injector") dropLabels.push("injector");
+
if (name !== "node-preload") dropLabels.push("nodePreload");
+
if (name !== "web-preload") dropLabels.push("webPreload");
+
+
const define = {
+
MOONLIGHT_ENV: `"${name}"`,
+
MOONLIGHT_PROD: prod.toString()
+
};
+
+
for (const iterName of Object.keys(config)) {
+
const snake = iterName.replace(/-/g, "_").toUpperCase();
+
define[`MOONLIGHT_${snake}`] = (name === iterName).toString();
+
}
+
+
const nodeDependencies = ["glob"];
+
const ignoredExternal = name === "web-preload" ? nodeDependencies : [];
+
+
const esbuildConfig = {
+
entryPoints: [entry],
+
outfile,
+
+
format: "cjs",
+
platform: name === "web-preload" ? "browser" : "node",
+
+
treeShaking: true,
+
bundle: true,
+
sourcemap: prod ? false : "inline",
+
+
external: [...ignoredExternal, ...external],
+
+
define,
+
dropLabels
+
};
+
+
if (watch) {
+
const ctx = await esbuild.context(esbuildConfig);
+
await ctx.watch();
+
} else {
+
await esbuild.build(esbuildConfig);
+
}
+
}
+
+
async function buildExt(ext, side, copyManifest, fileExt) {
+
const outDir = path.join("./dist", "core-extensions", ext);
+
if (!fs.existsSync(outDir)) {
+
fs.mkdirSync(outDir, { recursive: true });
+
}
+
+
const entryPoint = `packages/core-extensions/src/${ext}/${side}.${fileExt}`;
+
+
const esbuildConfig = {
+
entryPoints: [entryPoint],
+
outfile: path.join(outDir, side + ".js"),
+
+
format: "cjs",
+
platform: "node",
+
+
treeShaking: true,
+
bundle: true,
+
sourcemap: prod ? false : "inline",
+
+
external,
+
+
plugins: copyManifest
+
? [
+
copyStaticFiles({
+
src: `./packages/core-extensions/src/${ext}/manifest.json`,
+
dest: `./dist/core-extensions/${ext}/manifest.json`
+
})
+
]
+
: []
+
};
+
+
if (watch) {
+
const ctx = await esbuild.context(esbuildConfig);
+
await ctx.watch();
+
} else {
+
await esbuild.build(esbuildConfig);
+
}
+
}
+
+
const promises = [];
+
+
for (const [name, entry] of Object.entries(config)) {
+
promises.push(build(name, entry));
+
}
+
+
const coreExtensions = fs.readdirSync("./packages/core-extensions/src");
+
for (const ext of coreExtensions) {
+
let copiedManifest = false;
+
+
for (const fileExt of ["ts", "tsx"]) {
+
for (const type of ["index", "node", "host"]) {
+
if (
+
fs.existsSync(
+
`./packages/core-extensions/src/${ext}/${type}.${fileExt}`
+
)
+
) {
+
promises.push(buildExt(ext, type, !copiedManifest, fileExt));
+
copiedManifest = true;
+
}
+
}
+
}
+
}
+
+
await Promise.all(promises);
+1
env.d.ts
···
+
/// <reference types="./packages/types/src/index.d.ts" />
img/wordmark.png

This is a binary file and will not be displayed.

+21
package.json
···
+
{
+
"name": "moonlight",
+
"version": "1.0.0",
+
"description": "Yet another Discord mod",
+
"homepage": "https://github.com/moonlight-mod/moonlight#readme",
+
"repository": {
+
"type": "git",
+
"url": "git+https://github.com/moonlight-mod/moonlight.git"
+
},
+
"bugs": {
+
"url": "https://github.com/moonlight-mod/moonlight/issues"
+
},
+
"scripts": {
+
"build": "node build.mjs",
+
"dev": "node build.mjs --watch"
+
},
+
"devDependencies": {
+
"esbuild": "^0.19.3",
+
"esbuild-copy-static-files": "^0.1.0"
+
}
+
}
+8
packages/core-extensions/package.json
···
+
{
+
"name": "@moonlight-mod/core-extensions",
+
"private": true,
+
"dependencies": {
+
"@electron/asar": "^3.2.5",
+
"@moonlight-mod/types": "workspace:*"
+
}
+
}
+41
packages/core-extensions/src/common/components.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
import { CommonComponents } from "@moonlight-mod/types/coreExtensions";
+
+
export const components: ExtensionWebpackModule = {
+
dependencies: [
+
{ ext: "spacepack", id: "spacepack" },
+
"MasonryList:",
+
".flexGutterSmall,"
+
//"ALWAYS_WHITE:",
+
//".Messages.SWITCH_ACCOUNTS_TOAST_LOGIN_SUCCESS.format"
+
],
+
run: function (module, exports, require) {
+
const spacepack = require("spacepack_spacepack");
+
+
const Components = spacepack.findByCode("MasonryList:function")[0].exports;
+
const MarkdownParser = spacepack.findByCode(
+
"parseAutoModerationSystemMessage:"
+
)[0].exports.default;
+
const LegacyText = spacepack.findByCode(".selectable", ".colorStandard")[0]
+
.exports.default;
+
const Flex = spacepack.findByCode(".flex" + "GutterSmall,")[0].exports
+
.default;
+
const CardClasses = spacepack.findByCode("card", "cardHeader", "inModal")[0]
+
.exports;
+
const ControlClasses = spacepack.findByCode(
+
"title",
+
"titleDefault",
+
"dividerDefault"
+
)[0].exports;
+
+
let cache: Partial<CommonComponents> = {};
+
module.exports = {
+
...Components,
+
MarkdownParser,
+
LegacyText,
+
Flex,
+
CardClasses,
+
ControlClasses
+
};
+
}
+
};
+13
packages/core-extensions/src/common/flux.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
import { CommonFlux } from "@moonlight-mod/types/coreExtensions";
+
+
const findFlux = ["useStateFromStores:function"];
+
+
export const flux: ExtensionWebpackModule = {
+
dependencies: [{ ext: "spacepack", id: "spacepack" }, ...findFlux],
+
run: (module, exports, require) => {
+
const spacepack = require("spacepack_spacepack");
+
const Flux = spacepack.findByCode(...findFlux)[0].exports;
+
module.exports = Flux as CommonFlux;
+
}
+
};
+16
packages/core-extensions/src/common/fluxDispatcher.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
+
export const fluxDispatcher: ExtensionWebpackModule = {
+
dependencies: [
+
{ ext: "spacepack", id: "spacepack" },
+
"isDispatching",
+
"dispatch"
+
],
+
run: (module, exports, require) => {
+
const spacepack = require("spacepack_spacepack");
+
module.exports = spacepack.findByExports(
+
"isDispatching",
+
"dispatch"
+
)[0].exports.default;
+
}
+
};
+12
packages/core-extensions/src/common/http.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
+
const findHTTP = ["get", "put", "V8APIError"];
+
+
export const http: ExtensionWebpackModule = {
+
dependencies: [{ ext: "spacepack", id: "spacepack" }],
+
run: (module, exports, require) => {
+
const spacepack = require("spacepack_spacepack");
+
const HTTP = spacepack.findByExports(...findHTTP)[0].exports;
+
module.exports = HTTP.ZP ?? HTTP.Z;
+
}
+
};
+17
packages/core-extensions/src/common/index.ts
···
+
import { ExtensionWebExports } from "@moonlight-mod/types";
+
+
import { react } from "./react";
+
import { flux } from "./flux";
+
import { stores } from "./stores";
+
import { http } from "./http";
+
import { components } from "./components";
+
import { fluxDispatcher } from "./fluxDispatcher";
+
+
export const webpackModules: ExtensionWebExports["webpackModules"] = {
+
react,
+
flux,
+
stores,
+
http,
+
components,
+
fluxDispatcher
+
};
+10
packages/core-extensions/src/common/manifest.json
···
+
{
+
"id": "common",
+
"meta": {
+
"name": "Common",
+
"tagline": "A *lot* of common clientmodding utilities from the Discord client",
+
"authors": ["Cynosphere", "NotNite"],
+
"tags": ["library"]
+
},
+
"dependencies": ["spacepack"]
+
}
+15
packages/core-extensions/src/common/react.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
+
const findReact = [
+
"__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED",
+
/\.?version(?:=|:)/,
+
/\.?createElement(?:=|:)/
+
];
+
+
export const react: ExtensionWebpackModule = {
+
dependencies: [...findReact, { ext: "spacepack", id: "spacepack" }],
+
run: (module, exports, require) => {
+
const spacepack = require("spacepack_spacepack");
+
module.exports = spacepack.findByCode(...findReact)[0].exports;
+
}
+
};
+30
packages/core-extensions/src/common/stores.ts
···
+
import { ExtensionWebpackModule } from "@moonlight-mod/types";
+
+
export const stores: ExtensionWebpackModule = {
+
dependencies: [{ ext: "common", id: "flux" }],
+
run: (module, exports, require) => {
+
const Flux = require("common_flux");
+
+
module.exports = new Proxy(
+
{},
+
{
+
get: function (target, key, receiver) {
+
const allStores = Flux.Store.getAll();
+
+
let targetStore;
+
for (const store of allStores) {
+
const name = store.getName();
+
if (name.length == 1) continue; // filter out unnamed stores
+
+
if (name == key) {
+
targetStore = store;
+
break;
+
}
+
}
+
+
return targetStore;
+
}
+
}
+
);
+
}
+
};
+38
packages/core-extensions/src/disableSentry/host.ts
···
+
import { join } from "node:path";
+
import { Module } from "node:module";
+
import { BrowserWindow } from "electron";
+
+
const logger = moonlightHost.getLogger("disableSentry");
+
+
try {
+
const hostSentryPath = require.resolve(
+
join(moonlightHost.asarPath, "node_modules", "@sentry", "electron")
+
);
+
require.cache[hostSentryPath] = new Module(
+
hostSentryPath,
+
require.cache[require.resolve(moonlightHost.asarPath)]
+
);
+
require.cache[hostSentryPath]!.exports = {
+
init: () => {},
+
captureException: () => {},
+
setTag: () => {},
+
setUser: () => {}
+
};
+
logger.debug("Stubbed Sentry host side!");
+
} catch (err) {
+
logger.error("Failed to stub Sentry host side:", err);
+
}
+
+
moonlightHost.events.on("window-created", (window: BrowserWindow) => {
+
window.webContents.session.webRequest.onBeforeRequest(
+
{
+
urls: [
+
"https://*.sentry.io/*",
+
"https://*.discord.com/error-reporting-proxy/*"
+
]
+
},
+
function (details, callback) {
+
callback({ cancel: true });
+
}
+
);
+
});
+78
packages/core-extensions/src/disableSentry/index.ts
···
+
import { ExtensionWebExports } from "@moonlight-mod/types";
+
import { Patch, PatchReplaceType } from "@moonlight-mod/types";
+
+
export const patches: Patch[] = [
+
{
+
find: "DSN:function",
+
replace: {
+
type: PatchReplaceType.Normal,
+
match: /default:function\(\){return .}/,
+
replacement: 'default:function(){return require("disableSentry_stub")()}'
+
}
+
},
+
{
+
find: "window.DiscordSentry.addBreadcrumb",
+
replace: {
+
type: PatchReplaceType.Normal,
+
match: /default:function\(\){return .}/,
+
replacement:
+
'default:function(){return (...args)=>{moonlight.getLogger("disableSentry").debug("Sentry calling addBreadcrumb passthrough:", ...args);}}'
+
}
+
},
+
{
+
find: "initSentry:function",
+
replace: {
+
type: PatchReplaceType.Normal,
+
match: /initSentry:function\(\){return .}/,
+
replacement: "default:function(){return ()=>{}}"
+
}
+
},
+
{
+
find: "window.DiscordErrors=",
+
replace: {
+
type: PatchReplaceType.Normal,
+
match: /uses_client_mods:\(0,.\.usesClientMods\)\(\)/,
+
replacement: "uses_client_mods:false"
+
}
+
}
+
];
+
+
export const webpackModules: ExtensionWebExports["webpackModules"] = {
+
stub: {
+
run: function (module, exports, require) {
+
const logger = moonlight.getLogger("disableSentry");
+
+
const keys = [
+
"setUser",
+
"clearUser",
+
"setTags",
+
"setExtra",
+
"captureException",
+
"captureCrash",
+
"captureMessage",
+
"addBreadcrumb"
+
];
+
+
module.exports = () =>
+
new Proxy(
+
{},
+
{
+
get(target, prop, receiver) {
+
if (prop === "profiledRootComponent") {
+
return (arg: any) => arg;
+
} else if (prop === "crash") {
+
return () => {
+
throw Error("crash");
+
};
+
} else if (keys.includes(prop.toString())) {
+
return (...args: any[]) =>
+
logger.debug(`Sentry calling "${prop.toString()}":`, ...args);
+
} else {
+
return undefined;
+
}
+
}
+
}
+
);
+
}
+
}
+
};
+9
packages/core-extensions/src/disableSentry/manifest.json
···
+
{
+
"id": "disableSentry",
+
"meta": {
+
"name": "Disable Sentry",
+
"tagline": "Turns off Discord's error reporting systems",
+
"authors": ["Cynosphere", "NotNite"],
+
"tags": ["privacy"]
+
}
+
}
+25
packages/core-extensions/src/disableSentry/node.ts
···
+
import Module from "module";
+
import { ipcRenderer } from "electron";
+
import { resolve } from "path";
+
import { constants } from "@moonlight-mod/types";
+
+
const logger = moonlightNode.getLogger("disableSentry");
+
+
const preloadPath = ipcRenderer.sendSync(constants.ipcGetOldPreloadPath);
+
try {
+
const sentryPath = require.resolve(
+
resolve(preloadPath, "..", "node_modules", "@sentry", "electron")
+
);
+
require.cache[sentryPath] = new Module(
+
sentryPath,
+
require.cache[require.resolve(preloadPath)]
+
);
+
require.cache[sentryPath]!.exports = {
+
init: () => {},
+
setTag: () => {},
+
setUser: () => {}
+
};
+
logger.debug("Stubbed Sentry node side!");
+
} catch (err) {
+
logger.error("Failed to stub Sentry:", err);
+
}
+28
packages/core-extensions/src/experiments/index.ts
···
+
import { Patch } from "@moonlight-mod/types";
+
+
export const patches: Patch[] = [
+
{
+
find: /\.displayName="(Developer)?ExperimentStore"/,
+
replace: {
+
match: "window.GLOBAL_ENV.RELEASE_CHANNEL",
+
replacement: '"staging"'
+
}
+
},
+
{
+
find: '.displayName="DeveloperExperimentStore"',
+
replace: [
+
{
+
match: /CONNECTION_OPEN:.,OVERLAY_INITIALIZE:.,CURRENT_USER_UPDATE:./,
+
replacement: ""
+
},
+
{
+
match: '"production"',
+
replacement: '"development"'
+
},
+
{
+
match: /(get:function\(\){return .}}}\);).\(\);/,
+
replacement: "$1"
+
}
+
]
+
}
+
];
+9
packages/core-extensions/src/experiments/manifest.json
···
+
{
+
"id": "experiments",
+
"meta": {
+
"name": "Experiments",
+
"tagline": "Allows you to configure Discord's internal A/B testing features",
+
"authors": ["NotNite"],
+
"tags": ["dangerZone"]
+
}
+
}
+58
packages/core-extensions/src/moonbase/index.tsx
···
+
import { ExtensionWebExports } from "@moonlight-mod/types";
+
import ui from "./ui";
+
import { stores } from "./stores";
+
import { DownloadIconSVG, TrashIconSVG } from "./types";
+
+
export const webpackModules: ExtensionWebExports["webpackModules"] = {
+
stores: {
+
dependencies: [
+
{ ext: "common", id: "flux" },
+
{ ext: "common", id: "fluxDispatcher" }
+
],
+
run: (module, exports, require) => {
+
module.exports = stores(require);
+
}
+
},
+
+
moonbase: {
+
dependencies: [
+
{ ext: "spacepack", id: "spacepack" },
+
{ ext: "settings", id: "settings" },
+
{ ext: "common", id: "react" },
+
{ ext: "common", id: "components" },
+
{ ext: "moonbase", id: "stores" },
+
DownloadIconSVG,
+
TrashIconSVG
+
],
+
entrypoint: true,
+
run: (module, exports, require) => {
+
const settings = require("settings_settings");
+
const React = require("common_react");
+
const spacepack = require("spacepack_spacepack");
+
const { MoonbaseSettingsStore } =
+
require("moonbase_stores") as ReturnType<
+
typeof import("./stores")["stores"]
+
>;
+
+
settings.addSection("Moonbase", "Moonbase", ui(require), null, -2, {
+
stores: [MoonbaseSettingsStore],
+
element: () => {
+
// Require it here because lazy loading SUX
+
const SettingsNotice =
+
spacepack.findByCode("onSaveButtonColor")[0].exports.default;
+
return (
+
<SettingsNotice
+
submitting={MoonbaseSettingsStore.submitting}
+
onReset={() => {
+
MoonbaseSettingsStore.reset();
+
}}
+
onSave={() => {
+
MoonbaseSettingsStore.writeConfig();
+
}}
+
/>
+
);
+
}
+
});
+
}
+
}
+
};
+9
packages/core-extensions/src/moonbase/manifest.json
···
+
{
+
"id": "moonbase",
+
"meta": {
+
"name": "Moonbase",
+
"tagline": "The official settings UI for moonlight",
+
"authors": ["Cynosphere", "NotNite"]
+
},
+
"dependencies": ["spacepack", "settings", "common"]
+
}
+67
packages/core-extensions/src/moonbase/node.ts
···
+
import { MoonbaseNatives, RepositoryManifest } from "./types";
+
import asar from "@electron/asar";
+
import fs from "fs";
+
import path from "path";
+
import os from "os";
+
import { repoUrlFile } from "types/src/constants";
+
+
async function fetchRepositories(repos: string[]) {
+
const ret: Record<string, RepositoryManifest[]> = {};
+
+
for (const repo of repos) {
+
try {
+
const req = await fetch(repo);
+
const json = await req.json();
+
ret[repo] = json;
+
} catch (e) {
+
console.error(`Error fetching repository ${repo}`, e);
+
}
+
}
+
+
return ret;
+
}
+
+
async function installExtension(
+
manifest: RepositoryManifest,
+
url: string,
+
repo: string
+
) {
+
const req = await fetch(url);
+
+
const dir = moonlightNode.getExtensionDir(manifest.id);
+
// remake it in case of updates
+
if (fs.existsSync(dir)) fs.rmdirSync(dir, { recursive: true });
+
fs.mkdirSync(dir, { recursive: true });
+
+
// for some reason i just can't .writeFileSync() a file that ends in .asar???
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moonlight-"));
+
const tempFile = path.join(tempDir, "extension");
+
const buffer = await req.arrayBuffer();
+
fs.writeFileSync(tempFile, Buffer.from(buffer));
+
+
asar.extractAll(tempFile, dir);
+
fs.writeFileSync(path.join(dir, repoUrlFile), repo);
+
}
+
+
async function deleteExtension(id: string) {
+
const dir = moonlightNode.getExtensionDir(id);
+
fs.rmdirSync(dir, { recursive: true });
+
}
+
+
function getExtensionConfig(id: string, key: string): any {
+
const config = moonlightNode.config.extensions[id];
+
if (typeof config === "object") {
+
return config.config?.[key];
+
}
+
+
return undefined;
+
}
+
+
const exports: MoonbaseNatives = {
+
fetchRepositories,
+
installExtension,
+
deleteExtension,
+
getExtensionConfig
+
};
+
+
module.exports = exports;
+257
packages/core-extensions/src/moonbase/stores.ts
···
+
import WebpackRequire from "@moonlight-mod/types/discord/require";
+
import {
+
Config,
+
DetectedExtension,
+
ExtensionLoadSource
+
} from "@moonlight-mod/types";
+
import {
+
ExtensionState,
+
MoonbaseExtension,
+
MoonbaseNatives,
+
RepositoryManifest
+
} from "./types";
+
+
export const stores = (require: typeof WebpackRequire) => {
+
const Flux = require("common_flux");
+
const Dispatcher = require("common_fluxDispatcher");
+
const natives: MoonbaseNatives = moonlight.getNatives("moonbase");
+
+
class MoonbaseSettingsStore extends Flux.Store<any> {
+
private origConfig: Config;
+
private config: Config;
+
+
modified: boolean;
+
submitting: boolean;
+
installing: boolean;
+
+
extensions: { [id: string]: MoonbaseExtension };
+
updates: { [id: string]: { version: string; download: string } };
+
+
constructor() {
+
super(Dispatcher);
+
+
// Fucking Electron making it immutable
+
this.origConfig = moonlightNode.config;
+
this.config = JSON.parse(JSON.stringify(this.origConfig));
+
+
this.modified = false;
+
this.submitting = false;
+
this.installing = false;
+
+
this.extensions = {};
+
this.updates = {};
+
for (const ext of moonlightNode.extensions) {
+
const existingExtension = this.extensions[ext.id];
+
if (existingExtension != null) continue;
+
+
this.extensions[ext.id] = {
+
...ext,
+
state: moonlight.enabledExtensions.has(ext.id)
+
? ExtensionState.Enabled
+
: ExtensionState.Disabled
+
};
+
}
+
+
natives.fetchRepositories(this.config.repositories).then((ret) => {
+
for (const [repo, exts] of Object.entries(ret)) {
+
try {
+
for (const ext of exts) {
+
try {
+
const existingExtension = this.extensions[ext.id];
+
if (existingExtension != null) {
+
if (this.hasUpdate(repo, ext, existingExtension)) {
+
this.updates[ext.id] = {
+
version: ext.version!,
+
download: ext.download
+
};
+
}
+
continue;
+
}
+
+
this.extensions[ext.id] = {
+
id: ext.id,
+
manifest: ext,
+
source: { type: ExtensionLoadSource.Normal, url: repo },
+
state: ExtensionState.NotDownloaded
+
};
+
} catch (e) {
+
console.error(`Error processing extension ${ext.id}`, e);
+
}
+
}
+
} catch (e) {
+
console.error(`Error processing repository ${repo}`, e);
+
}
+
}
+
+
this.emitChange();
+
});
+
}
+
+
// this logic sucks so bad lol
+
private hasUpdate(
+
repo: string,
+
repoExt: RepositoryManifest,
+
existing: MoonbaseExtension
+
) {
+
return (
+
existing.source.type === ExtensionLoadSource.Normal &&
+
existing.source.url != null &&
+
existing.source.url === repo &&
+
repoExt.version != null &&
+
existing.manifest.version != repoExt.version
+
);
+
}
+
+
// Jank
+
private isModified() {
+
const orig = JSON.stringify(this.origConfig);
+
const curr = JSON.stringify(this.config);
+
return orig !== curr;
+
}
+
+
get busy() {
+
return this.submitting || this.installing;
+
}
+
+
showNotice() {
+
return this.modified;
+
}
+
+
getExtension(id: string) {
+
return this.extensions[id];
+
}
+
+
getExtensionName(id: string) {
+
return this.extensions.hasOwnProperty(id)
+
? this.extensions[id].manifest.meta?.name ?? id
+
: id;
+
}
+
+
getExtensionUpdate(id: string) {
+
return this.updates.hasOwnProperty(id) ? this.updates[id] : null;
+
}
+
+
getExtensionEnabled(id: string) {
+
const val = this.config.extensions[id];
+
if (val == null) return false;
+
return typeof val === "boolean" ? val : val.enabled;
+
}
+
+
getExtensionConfig<T>(id: string, key: string): T | undefined {
+
const defaultValue =
+
this.extensions[id].manifest.settings?.[key]?.default;
+
const cfg = this.config.extensions[id];
+
+
if (cfg == null || typeof cfg === "boolean") return defaultValue;
+
return cfg.config?.[key] ?? defaultValue;
+
}
+
+
getExtensionConfigName(id: string, key: string) {
+
return this.extensions[id].manifest.settings?.[key]?.displayName ?? key;
+
}
+
+
setExtensionConfig(id: string, key: string, value: any) {
+
const oldConfig = this.config.extensions[id];
+
const newConfig =
+
typeof oldConfig === "boolean"
+
? {
+
enabled: oldConfig,
+
config: { [key]: value }
+
}
+
: {
+
...oldConfig,
+
config: { ...(oldConfig?.config ?? {}), [key]: value }
+
};
+
+
this.config.extensions[id] = newConfig;
+
this.modified = this.isModified();
+
this.emitChange();
+
}
+
+
setExtensionEnabled(id: string, enabled: boolean) {
+
let val = this.config.extensions[id];
+
+
if (val == null) {
+
this.config.extensions[id] = { enabled };
+
this.emitChange();
+
return;
+
}
+
+
if (typeof val === "boolean") {
+
val = enabled;
+
} else {
+
val.enabled = enabled;
+
}
+
+
this.config.extensions[id] = val;
+
this.modified = this.isModified();
+
this.emitChange();
+
}
+
+
async installExtension(id: string) {
+
const ext = this.getExtension(id);
+
if (!("download" in ext.manifest)) {
+
throw new Error("Extension has no download URL");
+
}
+
+
this.installing = true;
+
try {
+
const url = this.updates[id]?.download ?? ext.manifest.download;
+
await natives.installExtension(ext.manifest, url, ext.source.url!);
+
if (ext.state === ExtensionState.NotDownloaded) {
+
this.extensions[id].state = ExtensionState.Disabled;
+
}
+
+
delete this.updates[id];
+
} catch (e) {
+
console.error("Error installing extension:", e);
+
}
+
+
this.installing = false;
+
this.emitChange();
+
}
+
+
async deleteExtension(id: string) {
+
const ext = this.getExtension(id);
+
if (ext == null) return;
+
+
this.installing = true;
+
try {
+
await natives.deleteExtension(ext.id);
+
this.extensions[id].state = ExtensionState.NotDownloaded;
+
} catch (e) {
+
console.error("Error deleting extension:", e);
+
}
+
+
this.installing = false;
+
this.emitChange();
+
}
+
+
writeConfig() {
+
this.submitting = true;
+
+
try {
+
moonlightNode.writeConfig(this.config);
+
// I love jank cloning
+
this.origConfig = JSON.parse(JSON.stringify(this.config));
+
} catch (e) {
+
console.error("Error writing config", e);
+
}
+
+
this.submitting = false;
+
this.modified = false;
+
this.emitChange();
+
}
+
+
reset() {
+
this.submitting = false;
+
this.modified = false;
+
this.config = JSON.parse(JSON.stringify(this.origConfig));
+
this.emitChange();
+
}
+
}
+
+
return {
+
MoonbaseSettingsStore: new MoonbaseSettingsStore()
+
};
+
};
+36
packages/core-extensions/src/moonbase/types.ts
···
+
import { DetectedExtension, ExtensionManifest } from "types/src";
+
+
export const DownloadIconSVG =
+
"M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z";
+
export const TrashIconSVG =
+
"M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z";
+
+
export type MoonbaseNatives = {
+
fetchRepositories(
+
repos: string[]
+
): Promise<Record<string, RepositoryManifest[]>>;
+
installExtension(
+
manifest: RepositoryManifest,
+
url: string,
+
repo: string
+
): Promise<void>;
+
deleteExtension(id: string): Promise<void>;
+
getExtensionConfig(id: string, key: string): any;
+
};
+
+
export type RepositoryManifest = ExtensionManifest & {
+
download: string;
+
};
+
+
export enum ExtensionState {
+
NotDownloaded,
+
Disabled,
+
Enabled
+
}
+
+
export type MoonbaseExtension = {
+
id: string;
+
manifest: ExtensionManifest | RepositoryManifest;
+
source: DetectedExtension["source"];
+
state: ExtensionState;
+
};
+228
packages/core-extensions/src/moonbase/ui/index.tsx
···
+
import WebpackRequire from "@moonlight-mod/types/discord/require";
+
import { DownloadIconSVG, ExtensionState, TrashIconSVG } from "../types";
+
import { ExtensionLoadSource } from "types/src";
+
import info from "./info";
+
import settings from "./settings";
+
+
export enum ExtensionPage {
+
Info,
+
Description,
+
Settings
+
}
+
+
export default (require: typeof WebpackRequire) => {
+
const React = require("common_react");
+
const spacepack = require("spacepack_spacepack");
+
const CommonComponents = require("common_components");
+
const Flux = require("common_flux");
+
+
const { ExtensionInfo } = info(require);
+
const { Settings } = settings(require);
+
const { MoonbaseSettingsStore } = require("moonbase_stores") as ReturnType<
+
typeof import("../stores")["stores"]
+
>;
+
+
const UserProfileClasses = spacepack.findByCode(
+
"tabBarContainer",
+
"topSection"
+
)[0].exports;
+
+
const DownloadIcon = spacepack.findByCode(DownloadIconSVG)[0].exports.default;
+
const TrashIcon = spacepack.findByCode(TrashIconSVG)[0].exports.default;
+
+
function ExtensionCard({ id }: { id: string }) {
+
const [tab, setTab] = React.useState(ExtensionPage.Info);
+
const { ext, enabled, busy, update } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
ext: MoonbaseSettingsStore.getExtension(id),
+
enabled: MoonbaseSettingsStore.getExtensionEnabled(id),
+
busy: MoonbaseSettingsStore.busy,
+
update: MoonbaseSettingsStore.getExtensionUpdate(id)
+
};
+
}
+
);
+
+
// Why it work like that :sob:
+
if (ext == null) return <></>;
+
+
const {
+
Card,
+
CardClasses,
+
Flex,
+
Text,
+
MarkdownParser,
+
Switch,
+
TabBar,
+
Button
+
} = CommonComponents;
+
+
const tagline = ext.manifest?.meta?.tagline;
+
const settings = ext.manifest?.settings;
+
const description = ext.manifest?.meta?.description;
+
+
return (
+
<Card editable={true} className={CardClasses.card}>
+
<div className={CardClasses.cardHeader}>
+
<Flex direction={Flex.Direction.VERTICAL}>
+
<Flex direction={Flex.Direction.HORIZONTAL}>
+
<Text variant="text-md/semibold">
+
{ext.manifest?.meta?.name ?? ext.id}
+
</Text>
+
</Flex>
+
+
{tagline != null && (
+
<Text variant="text-sm/normal">
+
{MarkdownParser.parse(tagline)}
+
</Text>
+
)}
+
</Flex>
+
+
<Flex
+
direction={Flex.Direction.HORIZONTAL}
+
align={Flex.Align.END}
+
justify={Flex.Justify.END}
+
>
+
{ext.state === ExtensionState.NotDownloaded ? (
+
<Button
+
color={Button.Colors.BRAND}
+
submitting={busy}
+
onClick={() => {
+
MoonbaseSettingsStore.installExtension(id);
+
}}
+
>
+
Install
+
</Button>
+
) : (
+
<div
+
// too lazy to learn how <Flex /> works lmao
+
style={{
+
display: "flex",
+
alignItems: "center",
+
gap: "1rem"
+
}}
+
>
+
{ext.source.type == ExtensionLoadSource.Normal && (
+
// TODO: this needs centering
+
<Button
+
color={Button.Colors.RED}
+
size={Button.Sizes.ICON}
+
submitting={busy}
+
onClick={() => {
+
MoonbaseSettingsStore.deleteExtension(id);
+
}}
+
>
+
<TrashIcon width={27} />
+
</Button>
+
)}
+
+
{update != null && (
+
<Button
+
color={Button.Colors.BRAND}
+
size={Button.Sizes.ICON}
+
submitting={busy}
+
onClick={() => {
+
MoonbaseSettingsStore.installExtension(id);
+
}}
+
>
+
<DownloadIcon width={27} />
+
</Button>
+
)}
+
+
<Switch
+
checked={enabled}
+
onChange={() => {
+
MoonbaseSettingsStore.setExtensionEnabled(id, !enabled);
+
}}
+
/>
+
</div>
+
)}
+
</Flex>
+
</div>
+
+
<div className={UserProfileClasses.body}>
+
{(description != null || settings != null) && (
+
<div
+
className={UserProfileClasses.tabBarContainer}
+
style={{
+
padding: "0 10px"
+
}}
+
>
+
<TabBar
+
selectedItem={tab}
+
type="top"
+
onItemSelect={setTab}
+
className={UserProfileClasses.tabBar}
+
>
+
<TabBar.Item
+
className={UserProfileClasses.tabBarItem}
+
id={ExtensionPage.Info}
+
>
+
Info
+
</TabBar.Item>
+
+
{description != null && (
+
<TabBar.Item
+
className={UserProfileClasses.tabBarItem}
+
id={ExtensionPage.Description}
+
>
+
Description
+
</TabBar.Item>
+
)}
+
+
{settings != null && (
+
<TabBar.Item
+
className={UserProfileClasses.tabBarItem}
+
id={ExtensionPage.Settings}
+
>
+
Settings
+
</TabBar.Item>
+
)}
+
</TabBar>
+
</div>
+
)}
+
+
<Flex
+
justify={Flex.Justify.START}
+
wrap={Flex.Wrap.WRAP}
+
style={{
+
padding: "16px 16px"
+
}}
+
>
+
{tab === ExtensionPage.Info && <ExtensionInfo ext={ext} />}
+
{tab === ExtensionPage.Description && (
+
<Text variant="text-md/normal">
+
{MarkdownParser.parse(description ?? "*No description*")}
+
</Text>
+
)}
+
{tab === ExtensionPage.Settings && <Settings ext={ext} />}
+
</Flex>
+
</div>
+
</Card>
+
);
+
}
+
+
return function Moonbase() {
+
const { extensions } = Flux.useStateFromStoresObject(
+
[MoonbaseSettingsStore],
+
() => {
+
return { extensions: MoonbaseSettingsStore.extensions };
+
}
+
);
+
+
const sorted = Object.values(extensions).sort((a, b) => {
+
const aName = a.manifest.meta?.name ?? a.id;
+
const bName = b.manifest.meta?.name ?? b.id;
+
return aName.localeCompare(bName);
+
});
+
+
return (
+
<>
+
{sorted.map((ext) => (
+
<ExtensionCard id={ext.id} key={ext.id} />
+
))}
+
</>
+
);
+
};
+
};
+198
packages/core-extensions/src/moonbase/ui/info.tsx
···
+
import WebpackRequire from "@moonlight-mod/types/discord/require";
+
import { DetectedExtension, ExtensionTag } from "@moonlight-mod/types";
+
import { MoonbaseExtension } from "../types";
+
+
type Dependency = {
+
id: string;
+
type: DependencyType;
+
};
+
+
enum DependencyType {
+
Dependency = "dependency",
+
Optional = "optional",
+
Incompatible = "incompatible"
+
}
+
+
export default (require: typeof WebpackRequire) => {
+
const React = require("common_react");
+
const spacepack = require("spacepack_spacepack");
+
+
const CommonComponents = require("common_components");
+
const UserInfoClasses = spacepack.findByCode(
+
"infoScroller",
+
"userInfoSection",
+
"userInfoSectionHeader"
+
)[0].exports;
+
+
const { MoonbaseSettingsStore } = require("moonbase_stores") as ReturnType<
+
typeof import("../stores")["stores"]
+
>;
+
+
function InfoSection({
+
title,
+
children
+
}: {
+
title: string;
+
children: React.ReactNode;
+
}) {
+
return (
+
<div
+
style={{
+
marginRight: "1em"
+
}}
+
>
+
<CommonComponents.Text
+
variant="eyebrow"
+
className={UserInfoClasses.userInfoSectionHeader}
+
>
+
{title}
+
</CommonComponents.Text>
+
+
<CommonComponents.Text variant="text-sm/normal">
+
{children}
+
</CommonComponents.Text>
+
</div>
+
);
+
}
+
+
function Badge({
+
color,
+
children
+
}: {
+
color: string;
+
children: React.ReactNode;
+
}) {
+
return (
+
<span
+
style={{
+
borderRadius: ".1875rem",
+
padding: "0 0.275rem",
+
marginRight: "0.4em",
+
backgroundColor: color,
+
color: "#fff"
+
}}
+
>
+
{children}
+
</span>
+
);
+
}
+
+
function ExtensionInfo({ ext }: { ext: MoonbaseExtension }) {
+
const { Flex, Text } = CommonComponents;
+
const authors = ext.manifest?.meta?.authors;
+
const tags = ext.manifest?.meta?.tags;
+
+
const dependencies: Dependency[] = [];
+
if (ext.manifest.dependencies != null) {
+
dependencies.push(
+
...ext.manifest.dependencies.map((dep) => ({
+
id: dep,
+
type: DependencyType.Dependency
+
}))
+
);
+
}
+
+
if (ext.manifest.suggested != null) {
+
dependencies.push(
+
...ext.manifest.suggested.map((dep) => ({
+
id: dep,
+
type: DependencyType.Optional
+
}))
+
);
+
}
+
+
if (ext.manifest.incompatible != null) {
+
dependencies.push(
+
...ext.manifest.incompatible.map((dep) => ({
+
id: dep,
+
type: DependencyType.Incompatible
+
}))
+
);
+
}
+
+
return (
+
<>
+
{authors != null && (
+
<InfoSection title="Authors">
+
{authors.map((author, i) => {
+
const comma = i !== authors.length - 1 ? ", " : "";
+
if (typeof author === "string") {
+
return (
+
<span>
+
{author}
+
{comma}
+
</span>
+
);
+
} else {
+
// TODO: resolve IDs
+
return (
+
<span>
+
{author.name}
+
{comma}
+
</span>
+
);
+
}
+
})}
+
</InfoSection>
+
)}
+
+
{tags != null && (
+
<InfoSection title="Tags">
+
{tags.map((tag, i) => {
+
const names: Record<ExtensionTag, string> = {
+
[ExtensionTag.Accessibility]: "Accessibility",
+
[ExtensionTag.Appearance]: "Appearance",
+
[ExtensionTag.Chat]: "Chat",
+
[ExtensionTag.Commands]: "Commands",
+
[ExtensionTag.ContextMenu]: "Context Menu",
+
[ExtensionTag.DangerZone]: "Danger Zone",
+
[ExtensionTag.Development]: "Development",
+
[ExtensionTag.Fixes]: "Fixes",
+
[ExtensionTag.Fun]: "Fun",
+
[ExtensionTag.Markdown]: "Markdown",
+
[ExtensionTag.Voice]: "Voice",
+
[ExtensionTag.Privacy]: "Privacy",
+
[ExtensionTag.Profiles]: "Profiles",
+
[ExtensionTag.QualityOfLife]: "Quality of Life",
+
[ExtensionTag.Library]: "Library"
+
};
+
const name = names[tag];
+
+
return (
+
<Badge
+
color={
+
tag == ExtensionTag.DangerZone
+
? "var(--red-400)"
+
: "var(--brand-500)"
+
}
+
>
+
{name}
+
</Badge>
+
);
+
})}
+
</InfoSection>
+
)}
+
+
{dependencies.length > 0 && (
+
<InfoSection title="Dependencies">
+
{dependencies.map((dep) => {
+
const colors = {
+
[DependencyType.Dependency]: "var(--brand-500)",
+
[DependencyType.Optional]: "var(--orange-400)",
+
[DependencyType.Incompatible]: "var(--red-400)"
+
};
+
const color = colors[dep.type];
+
const name = MoonbaseSettingsStore.getExtensionName(dep.id);
+
return <Badge color={color}>{name}</Badge>;
+
})}
+
</InfoSection>
+
)}
+
</>
+
);
+
}
+
+
return {
+
InfoSection,
+
ExtensionInfo
+
};
+
};
+327
packages/core-extensions/src/moonbase/ui/settings.tsx
···
+
import {
+
DictionarySettingType,
+
ExtensionSettingType,
+
ExtensionSettingsManifest,
+
NumberSettingType,
+
SelectSettingType
+
} from "@moonlight-mod/types/config";
+
import WebpackRequire from "@moonlight-mod/types/discord/require";
+
import { MoonbaseExtension } from "../types";
+
+
type SettingsProps = {
+
ext: MoonbaseExtension;
+
name: string;
+
setting: ExtensionSettingsManifest;
+
};
+
+
type SettingsComponent = React.ComponentType<SettingsProps>;
+
+
export default (require: typeof WebpackRequire) => {
+
const React = require("common_react");
+
const spacepack = require("spacepack_spacepack");
+
const CommonComponents = require("common_components");
+
const Flux = require("common_flux");
+
+
const { MoonbaseSettingsStore } = require("moonbase_stores") as ReturnType<
+
typeof import("../stores")["stores"]
+
>;
+
+
function Boolean({ ext, name, setting }: SettingsProps) {
+
const { FormSwitch } = CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value: MoonbaseSettingsStore.getExtensionConfig<boolean>(
+
ext.id,
+
name
+
),
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
return (
+
<FormSwitch
+
value={value ?? false}
+
hideBorder={true}
+
onChange={(value: boolean) => {
+
MoonbaseSettingsStore.setExtensionConfig(ext.id, name, value);
+
}}
+
>
+
{displayName}
+
</FormSwitch>
+
);
+
}
+
+
function Number({ ext, name, setting }: SettingsProps) {
+
const { Slider, ControlClasses } = CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value: MoonbaseSettingsStore.getExtensionConfig<number>(ext.id, name),
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
const castedSetting = setting as NumberSettingType;
+
const min = castedSetting.min ?? 0;
+
const max = castedSetting.max ?? 100;
+
+
return (
+
<div>
+
<label className={ControlClasses.title}>{displayName}</label>
+
<Slider
+
initialValue={value ?? 0}
+
minValue={castedSetting.min ?? 0}
+
maxValue={castedSetting.max ?? 100}
+
onValueChange={(value: number) => {
+
const rounded = Math.max(min, Math.min(max, Math.round(value)));
+
MoonbaseSettingsStore.setExtensionConfig(ext.id, name, rounded);
+
}}
+
/>
+
</div>
+
);
+
}
+
+
function String({ ext, name, setting }: SettingsProps) {
+
const { TextInput, ControlClasses } = CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value: MoonbaseSettingsStore.getExtensionConfig<string>(ext.id, name),
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
return (
+
<div>
+
<label className={ControlClasses.title}>{displayName}</label>
+
<TextInput
+
value={value ?? ""}
+
onChange={(value: string) => {
+
MoonbaseSettingsStore.setExtensionConfig(ext.id, name, value);
+
}}
+
/>
+
</div>
+
);
+
}
+
+
function Select({ ext, name, setting }: SettingsProps) {
+
const { ControlClasses, SingleSelect } = CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value: MoonbaseSettingsStore.getExtensionConfig<string>(ext.id, name),
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
const castedSetting = setting as SelectSettingType;
+
const options = castedSetting.options;
+
+
return (
+
<div>
+
<label className={ControlClasses.title}>{displayName}</label>
+
<SingleSelect
+
autofocus={false}
+
clearable={false}
+
value={value ?? ""}
+
options={options.map((o) => ({ value: o, label: o }))}
+
onChange={(value: string) => {
+
MoonbaseSettingsStore.setExtensionConfig(ext.id, name, value);
+
}}
+
/>
+
</div>
+
);
+
}
+
+
function List({ ext, name, setting }: SettingsProps) {
+
const { ControlClasses, Select, useVariableSelect, multiSelect } =
+
CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value:
+
MoonbaseSettingsStore.getExtensionConfig<string>(ext.id, name) ??
+
[],
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
const castedSetting = setting as SelectSettingType;
+
const options = castedSetting.options;
+
+
return (
+
<div>
+
<label className={ControlClasses.title}>{displayName}</label>
+
<Select
+
autofocus={false}
+
clearable={false}
+
options={options.map((o) => ({ value: o, label: o }))}
+
{...useVariableSelect({
+
onSelectInteraction: multiSelect,
+
value: new Set(Array.isArray(value) ? value : [value]),
+
onChange: (value: string) => {
+
MoonbaseSettingsStore.setExtensionConfig(
+
ext.id,
+
name,
+
Array.from(value)
+
);
+
}
+
})}
+
/>
+
</div>
+
);
+
}
+
+
function Dictionary({ ext, name, setting }: SettingsProps) {
+
const { TextInput, ControlClasses, Button, Flex } = CommonComponents;
+
const { value, displayName } = Flux.useStateFromStores(
+
[MoonbaseSettingsStore],
+
() => {
+
return {
+
value: MoonbaseSettingsStore.getExtensionConfig<
+
Record<string, string>
+
>(ext.id, name),
+
displayName: MoonbaseSettingsStore.getExtensionConfigName(
+
ext.id,
+
name
+
)
+
};
+
},
+
[ext.id, name]
+
);
+
+
const castedSetting = setting as DictionarySettingType;
+
const entries = Object.entries(value ?? {});
+
+
return (
+
<Flex direction={Flex.Direction.VERTICAL}>
+
<label className={ControlClasses.title}>{displayName}</label>
+
{entries.map(([key, val], i) => (
+
// FIXME: stylesheets
+
<div
+
key={i}
+
style={{
+
display: "grid",
+
height: "40px",
+
gap: "10px",
+
gridTemplateColumns: "1fr 1fr 40px"
+
}}
+
>
+
<TextInput
+
value={key}
+
onChange={(newKey: string) => {
+
entries[i][0] = newKey;
+
MoonbaseSettingsStore.setExtensionConfig(
+
ext.id,
+
name,
+
Object.fromEntries(entries)
+
);
+
}}
+
/>
+
<TextInput
+
value={val}
+
onChange={(newValue: string) => {
+
entries[i][1] = newValue;
+
MoonbaseSettingsStore.setExtensionConfig(
+
ext.id,
+
name,
+
Object.fromEntries(entries)
+
);
+
}}
+
/>
+
<Button
+
color={Button.Colors.RED}
+
size={Button.Sizes.ICON}
+
onClick={() => {
+
entries.splice(i, 1);
+
MoonbaseSettingsStore.setExtensionConfig(
+
ext.id,
+
name,
+
Object.fromEntries(entries)
+
);
+
}}
+
>
+
X
+
</Button>
+
</div>
+
))}
+
+
<Button
+
look={Button.Looks.FILLED}
+
color={Button.Colors.GREEN}
+
onClick={() => {
+
entries.push([`entry-${entries.length}`, ""]);
+
MoonbaseSettingsStore.setExtensionConfig(
+
ext.id,
+
name,
+
Object.fromEntries(entries)
+
);
+
}}
+
>
+
Add new entry
+
</Button>
+
</Flex>
+
);
+
}
+
+
function Setting({ ext, name, setting }: SettingsProps) {
+
const elements: Partial<Record<ExtensionSettingType, SettingsComponent>> = {
+
[ExtensionSettingType.Boolean]: Boolean,
+
[ExtensionSettingType.Number]: Number,
+
[ExtensionSettingType.String]: String,
+
[ExtensionSettingType.Select]: Select,
+
[ExtensionSettingType.List]: List,
+
[ExtensionSettingType.Dictionary]: Dictionary
+
};
+
const element = elements[setting.type];
+
if (element == null) return <></>;
+
return React.createElement(element, { ext, name, setting });
+
}
+
+
function Settings({ ext }: { ext: MoonbaseExtension }) {
+
const { Flex } = CommonComponents;
+
return (
+
<Flex direction={Flex.Direction.VERTICAL}>
+
{Object.entries(ext.manifest.settings!).map(([name, setting]) => (
+
<Setting ext={ext} key={name} name={name} setting={setting} />
+
))}
+
</Flex>
+
);
+
}
+
+
return {
+
Boolean,
+
Settings
+
};
+
};
+11
packages/core-extensions/src/noHideToken/index.ts
···
+
import { Patch } from "types/src";
+
+
export const patches: Patch[] = [
+
{
+
find: "hideToken(){",
+
replace: {
+
match: /hideToken\(\)\{.+?},/,
+
replacement: `hideToken(){},`
+
}
+
}
+
];
+9
packages/core-extensions/src/noHideToken/manifest.json
···
+
{
+
"id": "noHideToken",
+
"meta": {
+
"name": "No Hide Token",
+
"tagline": "Disables removal of token from localStorage when opening dev tools",
+
"authors": ["adryd"],
+
"tags": ["dangerZone", "development"]
+
}
+
}
+15
packages/core-extensions/src/noTrack/host.ts
···
+
import { BrowserWindow } from "electron";
+
+
moonlightHost.events.on("window-created", (window: BrowserWindow) => {
+
window.webContents.session.webRequest.onBeforeRequest(
+
{
+
urls: [
+
"https://*.discord.com/api/v*/science",
+
"https://*.discord.com/api/v*/metrics"
+
]
+
},
+
function (details, callback) {
+
callback({ cancel: true });
+
}
+
);
+
});
+18
packages/core-extensions/src/noTrack/index.ts
···
+
import { Patch, PatchReplaceType } from "@moonlight-mod/types";
+
+
export const patches: Patch[] = [
+
{
+
find: "analyticsTrackingStoreMaker:function",
+
replace: {
+
match: /analyticsTrackingStoreMaker:function\(\){return .}/,
+
replacement: "analyticsTrackingStoreMaker:function(){return ()=>{}}"
+
}
+
},
+
{
+
find: /this\._metrics\.push\(.\),/,
+
replace: {
+
match: /this\._metrics\.push\(.\),/,
+
replacement: ""
+
}
+
}
+
];
+9
packages/core-extensions/src/noTrack/manifest.json
···
+
{
+
"id": "noTrack",
+
"meta": {
+
"name": "No Track",
+
"tagline": "Disables /api/science and analytics",
+
"authors": ["Cynosphere", "NotNite"],
+
"tags": ["privacy"]
+
}
+
}
+111
packages/core-extensions/src/quietLoggers/index.ts
···
+
import { Patch } from "@moonlight-mod/types";
+
+
const notXssDefensesOnly = () =>
+
(moonlight.getConfigOption<boolean>("quietLoggers", "xssDefensesOnly") ??
+
false) === false;
+
+
// These patches MUST run before the simple patches, these are to remove loggers
+
// that end up causing syntax errors by the normal patch
+
const loggerFixes: Patch[] = [
+
{
+
find: '"./ggsans-800-extrabolditalic.woff2":',
+
replace: {
+
match: /\.then\(function\(\){var.+?"MODULE_NOT_FOUND",.\}\)/,
+
replacement: ".then(()=>(()=>{}))"
+
}
+
},
+
{
+
find: '("GatewaySocket")',
+
replace: {
+
match: /.\.(info|log)(\(.+?\))(;|,)/g,
+
replacement: (_, type, body, trail) => `(()=>{})${body}${trail}`
+
}
+
}
+
];
+
loggerFixes.forEach((patch) => {
+
patch.prerequisite = notXssDefensesOnly;
+
});
+
+
// Patches to simply remove a logger call
+
const stubPatches = [
+
// "sh" is not a valid locale.
+
[
+
"is not a valid locale",
+
/(.)\.error\(""\.concat\((.)\," is not a valid locale\."\)\)/g
+
],
+
['.displayName="RunningGameStore"', /.\.info\("games",{.+?}\),/],
+
[
+
'"[BUILD INFO] Release Channel: "',
+
/new\(0,.{1,2}\.default\)\(\)\.log\("\[BUILD INFO\] Release Channel: ".+?"\)\),/
+
],
+
[
+
'.AnalyticEvents.APP_NATIVE_CRASH,"Storage"',
+
/console\.log\("AppCrashedFatalReport lastCrash:",.,.\);/
+
],
+
[
+
'.AnalyticEvents.APP_NATIVE_CRASH,"Storage"',
+
'console.log("AppCrashedFatalReport: getLastCrash not supported.");'
+
],
+
[
+
'"[NATIVE INFO] ',
+
/new\(0,.{1,2}\.default\)\(\)\.log\("\[NATIVE INFO] .+?\)\),/
+
],
+
['"Spellchecker"', /.\.info\("Switching to ".+?"\(unavailable\)"\);?/g],
+
[
+
'throw new Error("Messages are still loading.");',
+
/console\.warn\("Unsupported Locale",.\);/
+
],
+
["_dispatchWithDevtools=", /.\.has\(.\.type\)&&.\.log\(.+?\);/],
+
["_dispatchWithDevtools=", /.\.totalTime>100&&.\.log\(.+?\);0;/],
+
[
+
'"NativeDispatchUtils"',
+
/null==.&&.\.warn\("Tried getting Dispatch instance before instantiated"\),/
+
],
+
[
+
'Error("Messages are still loading.")',
+
/console\.warn\("Unsupported Locale",.\),/
+
],
+
['("DatabaseManager")', /.\.log\("removing database \(user: ".+?\)\),/],
+
[
+
'"Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. Action: "',
+
/.\.has\(.\.type\)&&.\.log\(.+?\.type\)\),/
+
]
+
];
+
+
const simplePatches = [
+
// Moment.js deprecation warnings
+
["suppressDeprecationWarnings=!1", "suppressDeprecationWarnings=!0"],
+
+
// Zustand related
+
[
+
/console\.warn\("\[DEPRECATED\] Please use `subscribeWithSelector` middleware"\)/g,
+
"/*$&*/"
+
]
+
] as { [0]: string | RegExp; [1]: string }[];
+
+
export const patches: Patch[] = [
+
{
+
find: ".Messages.XSSDefenses",
+
replace: {
+
match: /\(null!=.{1,2}&&"0\.0\.0"===.{1,2}\.remoteApp\.getVersion\(\)\)/,
+
replacement: "(true)"
+
}
+
},
+
...loggerFixes,
+
...stubPatches.map((patch) => ({
+
find: patch[0],
+
replace: {
+
match: patch[1],
+
replacement: ""
+
},
+
prerequisite: notXssDefensesOnly
+
})),
+
...simplePatches.map((patch) => ({
+
find: patch[0],
+
replace: {
+
match: patch[0],
+
replacement: patch[1]
+
},
+
prerequisite: notXssDefensesOnly
+
}))
+
];
+17
packages/core-extensions/src/quietLoggers/manifest.json
···
+
{
+
"id": "quietLoggers",
+
"meta": {
+
"name": "Quiet Loggers",
+
"tagline": "Quiet errors on startup, and disable unnecesary loggers",
+
"authors": ["Cynosphere", "NotNite", "adryd"],
+
"tags": ["development"]
+
},
+
"settings": {
+
"xssDefensesOnly": {
+
"displayName": "Only hide self-XSS",
+
"description": "Only disable self XSS prevention log",
+
"type": "boolean",
+
"default": false
+
}
+
}
+
}
+78
packages/core-extensions/src/settings/index.ts
···
+
import { Patch, PatchReplaceType } from "@moonlight-mod/types";
+
import {
+
SettingsSection,
+
Settings as SettingsType
+
} from "@moonlight-mod/types/coreExtensions";
+
import { ExtensionWebExports, WebpackModuleFunc } from "@moonlight-mod/types";
+
+
export const patches: Patch[] = [
+
{
+
find: ".UserSettingsSections.EXPERIMENTS",
+
replace: {
+
match: /\.CUSTOM,element:(.+?)}\];return (.{1,2})/,
+
replacement: (_, lastElement, sections) =>
+
`.CUSTOM,element:${lastElement}}];return require("settings_settings")._mutateSections(${sections})`
+
}
+
},
+
{
+
find: 'navId:"user-settings-cog",',
+
replace: {
+
match: /children:\[(.)\.map\(.+?\),{children:.\((.)\)/,
+
replacement: (orig, sections, section) =>
+
`${orig}??${sections}.find(x=>x.section==${section})?._moonlight_submenu?.()`
+
}
+
}
+
];
+
+
export const webpackModules: ExtensionWebExports["webpackModules"] = {
+
settings: {
+
run: (module, exports, require) => {
+
const Settings: SettingsType = {
+
ourSections: [],
+
+
addSection: (section, label, element, color = null, pos, notice) => {
+
const data: SettingsSection = {
+
section,
+
label,
+
color,
+
element,
+
pos: pos ?? -4,
+
notice: notice
+
};
+
+
Settings.ourSections.push(data);
+
return data;
+
},
+
+
addDivider: (pos = null) => {
+
Settings.ourSections.push({
+
section: "DIVIDER",
+
pos: pos === null ? -4 : pos
+
});
+
},
+
+
addHeader: function (label, pos = null) {
+
Settings.ourSections.push({
+
section: "HEADER",
+
label: label,
+
pos: pos === null ? -4 : pos
+
});
+
},
+
+
_mutateSections: (sections) => {
+
for (const section of Settings.ourSections) {
+
sections.splice(
+
section.pos < 0 ? sections.length + section.pos : section.pos,
+
0,
+
section
+
);
+
}
+
+
return sections;
+
}
+
};
+
+
module.exports = Settings;
+
}
+
}
+
};
+9
packages/core-extensions/src/settings/manifest.json
···
+
{
+
"id": "settings",
+
"meta": {
+
"name": "Settings",
+
"tagline": "An API for adding to Discord's settings menu",
+
"authors": ["Cynosphere", "NotNite"],
+
"tags": ["library"]
+
}
+
}
+10
packages/core-extensions/src/spacepack/index.ts
···
+
import { ExtensionWebExports, WebpackModuleFunc } from "@moonlight-mod/types";
+
import webpackModule from "./webpackModule";
+
+
export const webpackModules: ExtensionWebExports["webpackModules"] = {
+
spacepack: {
+
entrypoint: true,
+
// Assert the type because we're adding extra fields to require
+
run: webpackModule as WebpackModuleFunc
+
}
+
};
+17
packages/core-extensions/src/spacepack/manifest.json
···
+
{
+
"id": "spacepack",
+
"meta": {
+
"name": "Spacepack",
+
"tagline": "Search utilities across all Webpack modules",
+
"authors": ["Cynosphere", "NotNite"],
+
"tags": ["library", "development"]
+
},
+
"settings": {
+
"addToGlobalScope": {
+
"displayName": "Add to global scope",
+
"description": "Populates window.spacepack for easier usage in DevTools",
+
"type": "boolean",
+
"default": false
+
}
+
}
+
}
+172
packages/core-extensions/src/spacepack/webpackModule.ts
···
+
import { WebpackModuleFunc, WebpackModule } from "@moonlight-mod/types";
+
import { Spacepack } from "@moonlight-mod/types/coreExtensions";
+
import { WebpackRequireType } from "@moonlight-mod/types/discord/webpack";
+
+
declare global {
+
interface Window {
+
spacepack: Spacepack;
+
}
+
}
+
+
export default (module: any, exports: any, require: WebpackRequireType) => {
+
const cache = require.c;
+
const modules = require.m;
+
+
const spacepack: Spacepack = {
+
require,
+
modules,
+
cache,
+
+
inspect: (module: number | string) => {
+
if (typeof module === "number") {
+
module = module.toString();
+
}
+
+
if (!(module in modules)) {
+
return null;
+
}
+
+
const func = modules[module];
+
if (func.__moonlight === true) {
+
return func;
+
}
+
+
const funcStr = func.toString();
+
+
return new Function(
+
"module",
+
"exports",
+
"require",
+
`(${funcStr}).apply(this, arguments)\n` +
+
`//# sourceURL=Webpack-Module-${module}`
+
) as WebpackModuleFunc;
+
},
+
+
findByCode: (...args: (string | RegExp)[]) => {
+
return Object.entries(modules)
+
.filter(
+
([id, mod]) =>
+
!args.some(
+
(item) =>
+
!(item instanceof RegExp
+
? item.test(mod.toString())
+
: mod.toString().indexOf(item) !== -1)
+
)
+
)
+
.map(([id]) => {
+
//if (!(id in cache)) require(id);
+
//return cache[id];
+
+
let exports;
+
try {
+
exports = require(id);
+
} catch (e) {
+
console.error(e);
+
debugger;
+
}
+
+
return {
+
id,
+
exports
+
};
+
})
+
.filter((item) => item != null);
+
},
+
+
findByExports: (...args: string[]) => {
+
return Object.entries(cache)
+
.filter(
+
([id, { exports }]) =>
+
!args.some(
+
(item) =>
+
!(
+
exports != undefined &&
+
exports != window &&
+
(exports?.[item] ||
+
exports?.default?.[item] ||
+
exports?.Z?.[item] ||
+
exports?.ZP?.[item])
+
)
+
)
+
)
+
.map((item) => item[1])
+
.reduce<WebpackModule[]>((prev, curr) => {
+
if (!prev.includes(curr)) prev.push(curr);
+
return prev;
+
}, []);
+
},
+
+
findObjectFromKey: (exports: Record<string, any>, key: string) => {
+
let subKey;
+
if (key.indexOf(".") > -1) {
+
const splitKey = key.split(".");
+
key = splitKey[0];
+
subKey = splitKey[1];
+
}
+
for (const exportKey in exports) {
+
const obj = exports[exportKey];
+
if (obj && obj[key] !== undefined) {
+
if (subKey) {
+
if (obj[key][subKey]) return obj;
+
} else {
+
return obj;
+
}
+
}
+
}
+
return null;
+
},
+
+
findObjectFromValue: (exports: Record<string, any>, value: any) => {
+
for (const exportKey in exports) {
+
const obj = exports[exportKey];
+
if (obj == value) return obj;
+
for (const subKey in obj) {
+
if (obj && obj[subKey] == value) {
+
return obj;
+
}
+
}
+
}
+
return null;
+
},
+
+
findObjectFromKeyValuePair: (
+
exports: Record<string, any>,
+
key: string,
+
value: any
+
) => {
+
for (const exportKey in exports) {
+
const obj = exports[exportKey];
+
if (obj && obj[key] == value) {
+
return obj;
+
}
+
}
+
return null;
+
},
+
+
findFunctionByStrings: (
+
exports: Record<string, any>,
+
...strings: (string | RegExp)[]
+
) => {
+
return (
+
Object.entries(exports).filter(
+
([index, func]) =>
+
typeof func === "function" &&
+
!strings.some(
+
(query) =>
+
!(query instanceof RegExp
+
? func.toString().match(query)
+
: func.toString().includes(query))
+
)
+
)?.[0]?.[1] ?? null
+
);
+
}
+
};
+
+
module.exports = spacepack;
+
+
if (
+
moonlight.getConfigOption<boolean>("spacepack", "addToGlobalScope") === true
+
) {
+
window.spacepack = spacepack;
+
}
+
};
+3
packages/core-extensions/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json"
+
}
+11
packages/core/package.json
···
+
{
+
"name": "@moonlight-mod/core",
+
"private": true,
+
"exports": {
+
"./*": "./src/*.ts"
+
},
+
"dependencies": {
+
"glob": "^10.3.4",
+
"@moonlight-mod/types": "workspace:*"
+
}
+
}
+48
packages/core/src/config.ts
···
+
import { Config, constants } from "@moonlight-mod/types";
+
import requireImport from "./util/import";
+
import { getConfigPath } from "./util/data";
+
+
const defaultConfig: Config = {
+
extensions: {},
+
repositories: []
+
};
+
+
export function writeConfig(config: Config) {
+
const fs = requireImport("fs");
+
const configPath = getConfigPath();
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+
}
+
+
function readConfigNode(): Config {
+
const fs = requireImport("fs");
+
const configPath = getConfigPath();
+
+
if (!fs.existsSync(configPath)) {
+
writeConfig(defaultConfig);
+
return defaultConfig;
+
}
+
+
let config: Config = JSON.parse(fs.readFileSync(configPath, "utf8"));
+
+
// Assign the default values if they don't exist (newly added)
+
config = { ...defaultConfig, ...config };
+
writeConfig(config);
+
+
return config;
+
}
+
+
export function readConfig(): Config {
+
webPreload: {
+
return moonlightNode.config;
+
}
+
+
nodePreload: {
+
return readConfigNode();
+
}
+
+
injector: {
+
return readConfigNode();
+
}
+
+
throw new Error("Called readConfig() in an impossible environment");
+
}
+107
packages/core/src/extension.ts
···
+
import {
+
ExtensionManifest,
+
DetectedExtension,
+
ExtensionLoadSource,
+
constants
+
} from "@moonlight-mod/types";
+
import { readConfig } from "./config";
+
import requireImport from "./util/import";
+
import { getCoreExtensionsPath, getExtensionsPath } from "./util/data";
+
+
function loadDetectedExtensions(
+
dir: string,
+
type: ExtensionLoadSource
+
): DetectedExtension[] {
+
const fs = requireImport("fs");
+
const path = requireImport("path");
+
const ret: DetectedExtension[] = [];
+
+
const glob = require("glob");
+
const manifests = glob.sync(dir + "/**/manifest.json");
+
+
for (const manifestPath of manifests) {
+
if (!fs.existsSync(manifestPath)) continue;
+
const dir = path.dirname(manifestPath);
+
+
const manifest: ExtensionManifest = JSON.parse(
+
fs.readFileSync(manifestPath, "utf8")
+
);
+
+
const webPath = path.join(dir, "index.js");
+
const nodePath = path.join(dir, "node.js");
+
const hostPath = path.join(dir, "host.js");
+
+
// if none exist (empty manifest) don't give a shit
+
if (
+
!fs.existsSync(webPath) &&
+
!fs.existsSync(nodePath) &&
+
!fs.existsSync(hostPath)
+
) {
+
continue;
+
}
+
+
const web = fs.existsSync(webPath)
+
? fs.readFileSync(webPath, "utf8")
+
: undefined;
+
+
let url: string | undefined = undefined;
+
const urlPath = path.join(dir, constants.repoUrlFile);
+
if (type == ExtensionLoadSource.Normal && fs.existsSync(urlPath)) {
+
url = fs.readFileSync(urlPath, "utf8");
+
}
+
+
ret.push({
+
id: manifest.id,
+
manifest,
+
source: {
+
type,
+
url
+
},
+
scripts: {
+
web,
+
webPath: web != null ? webPath : undefined,
+
nodePath: fs.existsSync(nodePath) ? nodePath : undefined,
+
hostPath: fs.existsSync(hostPath) ? hostPath : undefined
+
}
+
});
+
}
+
+
return ret;
+
}
+
+
function getExtensionsNative(): DetectedExtension[] {
+
const config = readConfig();
+
const res = [];
+
+
res.push(
+
...loadDetectedExtensions(getCoreExtensionsPath(), ExtensionLoadSource.Core)
+
);
+
+
res.push(
+
...loadDetectedExtensions(getExtensionsPath(), ExtensionLoadSource.Normal)
+
);
+
+
for (const devSearchPath of config.devSearchPaths ?? []) {
+
res.push(
+
...loadDetectedExtensions(devSearchPath, ExtensionLoadSource.Developer)
+
);
+
}
+
+
return res;
+
}
+
+
export function getExtensions(): DetectedExtension[] {
+
webPreload: {
+
return moonlightNode.extensions;
+
}
+
+
nodePreload: {
+
return getExtensionsNative();
+
}
+
+
injector: {
+
return getExtensionsNative();
+
}
+
+
throw new Error("Called getExtensions() outside of node-preload/web-preload");
+
}
+229
packages/core/src/extension/loader.ts
···
+
import {
+
ExtensionWebExports,
+
DetectedExtension,
+
ProcessedExtensions
+
} from "@moonlight-mod/types";
+
import { readConfig } from "../config";
+
import Logger from "../util/logger";
+
import { getExtensions } from "../extension";
+
import { registerPatch, registerWebpackModule } from "../patch";
+
import calculateDependencies from "../util/dependency";
+
import { createEventEmitter } from "../util/event";
+
+
const logger = new Logger("core/extension/loader");
+
+
async function loadExt(ext: DetectedExtension) {
+
webPreload: {
+
if (ext.scripts.web != null) {
+
const source =
+
ext.scripts.web + "\n//# sourceURL=file:///" + ext.scripts.webPath;
+
const fn = new Function("require", "module", "exports", source);
+
+
const module = { id: ext.id, exports: {} };
+
fn.apply(window, [
+
() => {
+
logger.warn("Attempted to require() from web");
+
},
+
module,
+
module.exports
+
]);
+
+
const exports: ExtensionWebExports = module.exports;
+
if (exports.patches != null) {
+
let idx = 0;
+
for (const patch of exports.patches) {
+
if (Array.isArray(patch.replace)) {
+
for (const replacement of patch.replace) {
+
const newPatch = Object.assign({}, patch, {
+
replace: replacement
+
});
+
+
registerPatch({ ...newPatch, ext: ext.id, id: idx });
+
idx++;
+
}
+
} else {
+
registerPatch({ ...patch, ext: ext.id, id: idx });
+
idx++;
+
}
+
}
+
}
+
+
if (exports.webpackModules != null) {
+
for (const [name, wp] of Object.entries(exports.webpackModules)) {
+
registerWebpackModule({ ...wp, ext: ext.id, id: name });
+
}
+
}
+
}
+
}
+
+
nodePreload: {
+
if (ext.scripts.nodePath != null) {
+
try {
+
const module = require(ext.scripts.nodePath);
+
moonlightNode.nativesCache[ext.id] = module;
+
} catch (e) {
+
logger.error(`Failed to load extension "${ext.id}"`, e);
+
}
+
}
+
}
+
+
injector: {
+
if (ext.scripts.hostPath != null) {
+
try {
+
require(ext.scripts.hostPath);
+
} catch (e) {
+
logger.error(`Failed to load extension "${ext.id}"`, e);
+
}
+
}
+
}
+
}
+
+
/*
+
This function resolves extensions and loads them, split into a few stages:
+
+
- Duplicate detection (removing multiple extensions with the same internal ID)
+
- Dependency resolution (creating a dependency graph & detecting circular dependencies)
+
- Failed dependency pruning
+
- Implicit dependency resolution (enabling extensions that are dependencies of other extensions)
+
- Loading all extensions
+
+
Instead of constructing an order from the dependency graph and loading
+
extensions synchronously, we load them in parallel asynchronously. Loading
+
extensions fires an event on completion, which allows us to await the loading
+
of another extension, resolving dependencies & load order effectively.
+
*/
+
export async function loadExtensions(
+
exts: DetectedExtension[]
+
): Promise<ProcessedExtensions> {
+
const items = exts
+
.map((ext) => {
+
return {
+
id: ext.id,
+
data: ext
+
};
+
})
+
.sort((a, b) => a.id.localeCompare(b.id));
+
+
const [sorted, dependencyGraph] = calculateDependencies(
+
items,
+
+
function fetchDep(id) {
+
return exts.find((x) => x.id === id) ?? null;
+
},
+
+
function getDeps(item) {
+
return item.data.manifest.dependencies ?? [];
+
},
+
+
function getIncompatible(item) {
+
return item.data.manifest.incompatible ?? [];
+
}
+
);
+
exts = sorted.map((x) => x.data);
+
+
logger.debug(
+
"Implicit dependency stage - extension list:",
+
exts.map((x) => x.id)
+
);
+
const config = readConfig();
+
const implicitlyEnabled: string[] = [];
+
+
function isEnabledInConfig(ext: DetectedExtension) {
+
if (implicitlyEnabled.includes(ext.id)) return true;
+
+
const entry = config.extensions[ext.id];
+
if (entry == null) return false;
+
+
if (entry === true) return true;
+
if (typeof entry === "object" && entry.enabled === true) return true;
+
+
return false;
+
}
+
+
function validateDeps(ext: DetectedExtension) {
+
if (isEnabledInConfig(ext)) {
+
const deps = dependencyGraph.get(ext.id)!;
+
for (const dep of deps.values()) {
+
validateDeps(exts.find((e) => e.id === dep)!);
+
}
+
} else {
+
const dependsOnMe = Array.from(dependencyGraph.entries()).filter(
+
([, v]) => v?.has(ext.id)
+
);
+
+
if (dependsOnMe.length > 0) {
+
logger.debug("Implicitly enabling extension", ext.id);
+
implicitlyEnabled.push(ext.id);
+
}
+
}
+
}
+
+
for (const ext of exts) validateDeps(ext);
+
exts = exts.filter((e) => isEnabledInConfig(e));
+
+
return {
+
extensions: exts,
+
dependencyGraph
+
};
+
}
+
+
export async function loadProcessedExtensions({
+
extensions,
+
dependencyGraph
+
}: ProcessedExtensions) {
+
const eventEmitter = createEventEmitter();
+
const finished: Set<string> = new Set();
+
+
logger.debug(
+
"Load stage - extension list:",
+
extensions.map((x) => x.id)
+
);
+
+
async function loadExtWithDependencies(ext: DetectedExtension) {
+
const deps = Array.from(dependencyGraph.get(ext.id)!);
+
+
// Wait for the dependencies to finish
+
const waitPromises = deps.map(
+
(dep: string) =>
+
new Promise<void>((r) => {
+
function cb(eventDep: string) {
+
if (eventDep === dep) {
+
done();
+
}
+
}
+
+
function done() {
+
eventEmitter.removeEventListener("ext-ready", cb);
+
r();
+
}
+
+
eventEmitter.addEventListener("ext-ready", cb);
+
if (finished.has(dep)) done();
+
})
+
);
+
+
if (waitPromises.length > 0) {
+
logger.debug(
+
`Waiting on ${waitPromises.length} dependencies for "${ext.id}"`
+
);
+
await Promise.all(waitPromises);
+
}
+
+
logger.debug(`Loading "${ext.id}"`);
+
await loadExt(ext);
+
+
finished.add(ext.id);
+
eventEmitter.dispatchEvent("ext-ready", ext.id);
+
logger.debug(`Loaded "${ext.id}"`);
+
}
+
+
webPreload: {
+
for (const ext of extensions) {
+
moonlight.enabledExtensions.add(ext.id);
+
}
+
}
+
+
logger.debug("Loading all extensions");
+
await Promise.all(extensions.map(loadExtWithDependencies));
+
logger.info(`Loaded ${extensions.length} extensions`);
+
}
+333
packages/core/src/patch.ts
···
+
import {
+
PatchReplace,
+
PatchReplaceType,
+
ExplicitExtensionDependency,
+
IdentifiedPatch,
+
IdentifiedWebpackModule,
+
WebpackJsonp,
+
WebpackJsonpEntry,
+
WebpackModuleFunc
+
} from "@moonlight-mod/types";
+
import Logger from "./util/logger";
+
import calculateDependencies, { Dependency } from "./util/dependency";
+
import WebpackRequire from "@moonlight-mod/types/discord/require";
+
+
const logger = new Logger("core/patch");
+
+
// Can't be Set because we need splice
+
let patches: IdentifiedPatch[] = [];
+
let webpackModules: Set<IdentifiedWebpackModule> = new Set();
+
+
export function registerPatch(patch: IdentifiedPatch) {
+
patches.push(patch);
+
}
+
+
export function registerWebpackModule(wp: IdentifiedWebpackModule) {
+
webpackModules.add(wp);
+
}
+
+
/*
+
The patching system functions by matching a string or regex against the
+
.toString()'d copy of a Webpack module. When a patch happens, we reconstruct
+
the module with the patched source and replace it, wrapping it in the process.
+
+
We keep track of what modules we've patched (and their original sources), both
+
so we don't wrap them twice and so we can debug what extensions are patching
+
what Webpack modules.
+
*/
+
const moduleCache: Record<string, string> = {};
+
const patched: Record<string, Array<string>> = {};
+
+
function patchModules(entry: WebpackJsonpEntry[1]) {
+
for (const [id, func] of Object.entries(entry)) {
+
let moduleString = moduleCache.hasOwnProperty(id)
+
? moduleCache[id]
+
: func.toString().replace(/\n/g, "");
+
+
for (const patch of patches) {
+
if (patch.prerequisite != null && !patch.prerequisite()) {
+
continue;
+
}
+
+
if (patch.find instanceof RegExp && patch.find.global) {
+
// Reset state because global regexes are stateful for some reason
+
patch.find.lastIndex = 0;
+
}
+
+
// indexOf is faster than includes by 0.25% lmao
+
const match =
+
typeof patch.find === "string"
+
? moduleString.indexOf(patch.find) !== -1
+
: patch.find.test(moduleString);
+
+
// Global regexes apply to all modules
+
const shouldRemove =
+
typeof patch.find === "string" ? true : !patch.find.global;
+
+
if (match) {
+
moonlight.unpatched.delete(patch);
+
+
// We ensured all arrays get turned into normal PatchReplace objects on register
+
const replace = patch.replace as PatchReplace;
+
+
if (
+
replace.type === undefined ||
+
replace.type == PatchReplaceType.Normal
+
) {
+
// tsc fails to detect the overloads for this, so I'll just do this
+
// Verbose, but it works
+
let replaced;
+
if (typeof replace.replacement === "string") {
+
replaced = moduleString.replace(replace.match, replace.replacement);
+
} else {
+
replaced = moduleString.replace(replace.match, replace.replacement);
+
}
+
+
if (replaced === moduleString) {
+
logger.warn("Patch replacement failed", id, patch);
+
continue;
+
}
+
+
// Store what extensions patched what modules for easier debugging
+
patched[id] = patched[id] || [];
+
patched[id].push(`${patch.ext}#${patch.id}`);
+
+
// Webpack module arguments are minified, so we replace them with consistent names
+
// We have to wrap it so things don't break, though
+
const patchedStr = patched[id].sort().join(", ");
+
+
const wrapped =
+
`(${replaced}).apply(this, arguments)\n` +
+
`// Patched by moonlight: ${patchedStr}\n` +
+
`//# sourceURL=Webpack-Module-${id}`;
+
+
try {
+
const func = new Function(
+
"module",
+
"exports",
+
"require",
+
wrapped
+
) as WebpackModuleFunc;
+
entry[id] = func;
+
entry[id].__moonlight = true;
+
moduleString = replaced;
+
} catch (e) {
+
logger.warn("Error constructing function for patch", e);
+
}
+
} else if (replace.type == PatchReplaceType.Module) {
+
// Directly replace the module with a new one
+
const newModule = replace.replacement(moduleString);
+
entry[id] = newModule;
+
entry[id].__moonlight = true;
+
moduleString =
+
newModule.toString().replace(/\n/g, "") +
+
`//# sourceURL=Webpack-Module-${id}`;
+
}
+
+
if (shouldRemove) {
+
patches.splice(
+
patches.findIndex((p) => p.ext === patch.ext && p.id === patch.id),
+
1
+
);
+
}
+
}
+
}
+
+
if (moonlightNode.config.patchAll === true) {
+
if (
+
(typeof id !== "string" || !id.includes("_")) &&
+
!entry[id].__moonlight
+
) {
+
const wrapped =
+
`(${moduleString}).apply(this, arguments)\n` +
+
`//# sourceURL=Webpack-Module-${id}`;
+
entry[id] = new Function(
+
"module",
+
"exports",
+
"require",
+
wrapped
+
) as WebpackModuleFunc;
+
entry[id].__moonlight = true;
+
}
+
}
+
+
moduleCache[id] = moduleString;
+
}
+
}
+
+
/*
+
Similar to patching, we also want to inject our own custom Webpack modules
+
into Discord's Webpack instance. We abuse pollution on the push function to
+
mark when we've completed it already.
+
*/
+
let chunkId = Number.MAX_SAFE_INTEGER;
+
+
function handleModuleDependencies() {
+
const modules = Array.from(webpackModules.values());
+
+
const dependencies: Dependency<string, IdentifiedWebpackModule>[] =
+
modules.map((wp) => {
+
return {
+
id: `${wp.ext}_${wp.id}`,
+
data: wp
+
};
+
});
+
+
const [sorted, _] = calculateDependencies(
+
dependencies,
+
+
function fetchDep(id) {
+
return modules.find((x) => id === `${x.ext}_${x.id}`) ?? null;
+
},
+
+
function getDeps(item) {
+
const deps = item.data?.dependencies ?? [];
+
return (
+
deps.filter(
+
(dep) => !(dep instanceof RegExp || typeof dep === "string")
+
) as ExplicitExtensionDependency[]
+
).map((x) => `${x.ext}_${x.id}`);
+
}
+
);
+
+
webpackModules = new Set(sorted.map((x) => x.data));
+
}
+
+
const injectedWpModules: IdentifiedWebpackModule[] = [];
+
function injectModules(entry: WebpackJsonpEntry[1]) {
+
const modules: Record<string, WebpackModuleFunc> = {};
+
const entrypoints: string[] = [];
+
let inject = false;
+
+
for (const [modId, mod] of Object.entries(entry)) {
+
const modStr = mod.toString();
+
const wpModules = Array.from(webpackModules.values());
+
for (const wpModule of wpModules) {
+
const id = wpModule.ext + "_" + wpModule.id;
+
if (wpModule.dependencies) {
+
const deps = new Set(wpModule.dependencies);
+
+
// FIXME: This dependency resolution might fail if the things we want
+
// got injected earlier. If weird dependencies fail, this is likely why.
+
if (deps.size) {
+
for (const dep of deps.values()) {
+
if (typeof dep === "string") {
+
if (modStr.includes(dep)) deps.delete(dep);
+
} else if (dep instanceof RegExp) {
+
if (dep.test(modStr)) deps.delete(dep);
+
} else if (
+
injectedWpModules.find(
+
(x) => x.ext === dep.ext && x.id === dep.id
+
)
+
) {
+
deps.delete(dep);
+
}
+
}
+
+
if (deps.size !== 0) {
+
// Update the deps that have passed
+
webpackModules.delete(wpModule);
+
wpModule.dependencies = Array.from(deps);
+
webpackModules.add(wpModule);
+
continue;
+
}
+
+
wpModule.dependencies = Array.from(deps);
+
}
+
}
+
+
webpackModules.delete(wpModule);
+
injectedWpModules.push(wpModule);
+
+
inject = true;
+
+
modules[id] = wpModule.run;
+
if (wpModule.entrypoint) entrypoints.push(id);
+
}
+
if (!webpackModules.size) break;
+
}
+
+
if (inject) {
+
logger.debug("Injecting modules:", modules, entrypoints);
+
window.webpackChunkdiscord_app.push([
+
[--chunkId],
+
modules,
+
(require: typeof WebpackRequire) => entrypoints.map(require)
+
]);
+
}
+
}
+
+
declare global {
+
interface Window {
+
webpackChunkdiscord_app: WebpackJsonp;
+
}
+
}
+
+
/*
+
Webpack modules are bundled into an array of arrays that hold each function.
+
Since we run code before Discord, we can create our own Webpack array and
+
hijack the .push function on it.
+
+
From there, we iterate over the object (mapping IDs to functions) and patch
+
them accordingly.
+
*/
+
export async function installWebpackPatcher() {
+
await handleModuleDependencies();
+
+
let realWebpackJsonp: WebpackJsonp | null = null;
+
Object.defineProperty(window, "webpackChunkdiscord_app", {
+
set: (jsonp: WebpackJsonp) => {
+
// Don't let Sentry mess with Webpack
+
const stack = new Error().stack!;
+
if (stack.includes("sentry.")) return;
+
+
realWebpackJsonp = jsonp;
+
const realPush = jsonp.push;
+
if (jsonp.push.__moonlight !== true) {
+
jsonp.push = (items) => {
+
patchModules(items[1]);
+
+
try {
+
const res = realPush.apply(realWebpackJsonp, [items]);
+
if (!realPush.__moonlight) {
+
logger.trace("Injecting Webpack modules", items[1]);
+
injectModules(items[1]);
+
}
+
+
return res;
+
} catch (err) {
+
logger.error("Failed to inject Webpack modules:", err);
+
return 0;
+
}
+
};
+
+
jsonp.push.bind = (thisArg: any, ...args: any[]) => {
+
return realPush.bind(thisArg, ...args);
+
};
+
+
jsonp.push.__moonlight = true;
+
if (!realPush.__moonlight) {
+
logger.debug("Injecting Webpack modules with empty entry");
+
// Inject an empty entry to cause iteration to happen once
+
// Kind of a dirty hack but /shrug
+
injectModules({ deez: () => {} });
+
}
+
}
+
},
+
+
get: () => {
+
const stack = new Error().stack!;
+
if (stack.includes("sentry.")) return [];
+
return realWebpackJsonp;
+
}
+
});
+
+
registerWebpackModule({
+
ext: "moonlight",
+
id: "fix_rspack_init_modules",
+
entrypoint: true,
+
run: function (module, exports, require) {
+
patchModules(require.m);
+
}
+
});
+
}
packages/core/src/util/clone.ts

This is a binary file and will not be displayed.

+65
packages/core/src/util/data.ts
···
+
import { constants } from "@moonlight-mod/types";
+
import requireImport from "./import";
+
+
export function getMoonlightDir(): string {
+
const { app, ipcRenderer } = require("electron");
+
const fs = requireImport("fs");
+
const path = requireImport("path");
+
+
let appData = "";
+
injector: {
+
appData = app.getPath("appData");
+
}
+
+
nodePreload: {
+
appData = ipcRenderer.sendSync(constants.ipcGetAppData);
+
}
+
+
const dir = path.join(appData, "moonlight-mod");
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
+
+
return dir;
+
}
+
+
type BuildInfo = {
+
releaseChannel: string;
+
version: string;
+
};
+
+
export function getConfigPath(): string {
+
const dir = getMoonlightDir();
+
const fs = requireImport("fs");
+
const path = requireImport("path");
+
+
const buildInfoPath = path.join(process.resourcesPath, "build_info.json");
+
const buildInfo: BuildInfo = JSON.parse(
+
fs.readFileSync(buildInfoPath, "utf8")
+
);
+
+
const configPath = path.join(dir, buildInfo.releaseChannel + ".json");
+
return configPath;
+
}
+
+
function getPathFromMoonlight(...names: string[]): string {
+
const dir = getMoonlightDir();
+
const fs = requireImport("fs");
+
const path = requireImport("path");
+
+
const target = path.join(dir, ...names);
+
if (!fs.existsSync(target)) fs.mkdirSync(target);
+
+
return target;
+
}
+
+
export function getExtensionsPath(): string {
+
return getPathFromMoonlight(constants.extensionsDir);
+
}
+
+
export function getCoreExtensionsPath(): string {
+
if (MOONLIGHT_PROD) {
+
return getPathFromMoonlight(constants.distDir, constants.coreExtensionsDir);
+
} else {
+
const path = requireImport("path");
+
return path.join(__dirname, constants.coreExtensionsDir);
+
}
+
}
+121
packages/core/src/util/dependency.ts
···
+
import Logger from "./logger";
+
+
export type Dependency<T, D> = {
+
id: T;
+
data: D;
+
};
+
+
const logger = new Logger("core/util/dependency");
+
+
export default function calculateDependencies<T, D>(
+
origItems: Dependency<T, D>[],
+
fetchDep: (id: T) => D | null,
+
getDeps: (item: Dependency<T, D>) => T[],
+
getIncompatible?: (item: Dependency<T, D>) => T[]
+
): [Dependency<T, D>[], Map<T, Set<T> | null>] {
+
logger.trace("sortDependencies begin", origItems);
+
let items = [...origItems];
+
+
if (getIncompatible != null) {
+
for (const item of items) {
+
const incompatibleItems = getIncompatible(item);
+
for (const incompatibleItem of incompatibleItems) {
+
if (items.find((x) => x.id === incompatibleItem) != null) {
+
logger.warn(
+
`Incompatible dependency detected: "${item.id}" and "${incompatibleItem}" - removing "${incompatibleItem}"`
+
);
+
+
items = items.filter((x) => x.id !== incompatibleItem);
+
}
+
}
+
}
+
}
+
+
const dependencyGraph = new Map<T, Set<T> | null>();
+
for (const item of items) {
+
const fullDeps: Set<T> = new Set();
+
let failed = false;
+
+
function resolveDeps(id: T, root: boolean) {
+
if (id === item.id && !root) {
+
logger.warn(`Circular dependency detected: "${item.id}"`);
+
failed = true;
+
return;
+
}
+
+
const obj = fetchDep(id);
+
if (obj == null) {
+
logger.warn(`Missing dependency detected`, id);
+
failed = true;
+
return;
+
}
+
+
if (!root) fullDeps.add(id);
+
+
for (const dep of getDeps({ id, data: obj })) {
+
resolveDeps(dep, false);
+
}
+
}
+
+
resolveDeps(item.id, true);
+
dependencyGraph.set(item.id, failed ? null : fullDeps);
+
}
+
+
logger.trace("Failed stage", items);
+
function isFailed(id: T) {
+
const deps = dependencyGraph.get(id);
+
if (deps === null) return true;
+
+
// For some reason this can be undefined. If it is, it's not null, so we
+
// didn't explicitly fail. FIXME too tired to investigate
+
if (deps === undefined) return false;
+
+
for (const dep of deps) {
+
if (isFailed(dep)) return true;
+
}
+
+
return false;
+
}
+
+
const failed = items.filter((item) => isFailed(item.id));
+
if (failed.length > 0) {
+
logger.warn("Skipping failed items", failed);
+
items = items.filter((item) => !failed.includes(item));
+
}
+
+
logger.trace("Sorting stage", items);
+
const sorted: Dependency<T, D>[] = [];
+
+
// Clone the dependency graph to return it later
+
const backupDependencyGraph = new Map(dependencyGraph);
+
for (const item of items) {
+
dependencyGraph.set(item.id, new Set(dependencyGraph.get(item.id)));
+
}
+
+
while (
+
Array.from(dependencyGraph.values()).filter((x) => x != null).length > 0
+
) {
+
const noDependents = items.filter(
+
(e) => dependencyGraph.get(e.id)?.size === 0
+
);
+
+
if (noDependents.length === 0) {
+
logger.warn("Stuck dependency graph detected", dependencyGraph);
+
break;
+
}
+
+
for (const item of noDependents) {
+
sorted.push(item);
+
dependencyGraph.delete(item.id);
+
}
+
+
for (const deps of dependencyGraph.values()) {
+
for (const item of noDependents) {
+
deps?.delete(item.id);
+
}
+
}
+
}
+
+
logger.trace("sortDependencies end", sorted);
+
return [sorted, backupDependencyGraph];
+
}
+79
packages/core/src/util/event.ts
···
+
export type MoonlightEventCallback = (data: string) => void;
+
+
export interface MoonlightEventEmitter {
+
dispatchEvent: (id: string, data: string) => void;
+
addEventListener: (id: string, cb: MoonlightEventCallback) => void;
+
removeEventListener: (id: string, cb: MoonlightEventCallback) => void;
+
}
+
+
function nodeMethod(): MoonlightEventEmitter {
+
const EventEmitter = require("events");
+
const eventEmitter = new EventEmitter();
+
const listeners = new Map<MoonlightEventCallback, (...args: any[]) => void>();
+
+
return {
+
dispatchEvent: (id: string, data: string) => {
+
eventEmitter.emit(id, data);
+
},
+
+
addEventListener: (id: string, cb: (data: string) => void) => {
+
if (listeners.has(cb)) return;
+
+
function listener(data: string) {
+
cb(data);
+
}
+
+
listeners.set(cb, listener);
+
eventEmitter.on(id, listener);
+
},
+
+
removeEventListener: (id: string, cb: (data: string) => void) => {
+
const listener = listeners.get(cb);
+
if (listener == null) return;
+
listeners.delete(cb);
+
eventEmitter.off(id, listener);
+
}
+
};
+
}
+
+
export function createEventEmitter(): MoonlightEventEmitter {
+
webPreload: {
+
const eventEmitter = new EventTarget();
+
const listeners = new Map<MoonlightEventCallback, (e: Event) => void>();
+
+
return {
+
dispatchEvent: (id: string, data: string) => {
+
eventEmitter.dispatchEvent(new CustomEvent(id, { detail: data }));
+
},
+
+
addEventListener: (id: string, cb: (data: string) => void) => {
+
if (listeners.has(cb)) return;
+
+
function listener(e: Event) {
+
const event = e as CustomEvent<string>;
+
cb(event.detail);
+
}
+
+
listeners.set(cb, listener);
+
eventEmitter.addEventListener(id, listener);
+
},
+
+
removeEventListener: (id: string, cb: (data: string) => void) => {
+
const listener = listeners.get(cb);
+
if (listener == null) return;
+
listeners.delete(cb);
+
eventEmitter.removeEventListener(id, listener);
+
}
+
};
+
}
+
+
nodePreload: {
+
return nodeMethod();
+
}
+
+
injector: {
+
return nodeMethod();
+
}
+
+
throw new Error("Called createEventEmitter() in an impossible environment");
+
}
+25
packages/core/src/util/import.ts
···
+
/*
+
For tree shaking reasons, sometimes we need to require() instead of an import
+
statement at the top of the module (like config, which runs node *and* web).
+
+
require() doesn't seem to carry the types from @types/node, so this allows us
+
to requireImport("fs") and still keep the types of fs.
+
+
In the future, I'd like to automate ImportTypes, but I think the type is only
+
cemented if import is passed a string literal.
+
*/
+
+
const canRequire = ["path", "fs", "glob"] as const;
+
type CanRequire = (typeof canRequire)[number];
+
+
type ImportTypes = {
+
path: typeof import("path");
+
fs: typeof import("fs");
+
glob: typeof import("glob");
+
};
+
+
export default function requireImport<T extends CanRequire>(
+
type: T
+
): Awaited<ImportTypes[T]> {
+
return require(type);
+
}
+93
packages/core/src/util/logger.ts
···
+
import { LogLevel } from "@moonlight-mod/types/logger";
+
import { readConfig } from "../config";
+
+
const colors = {
+
[LogLevel.SILLY]: "#EDD3E9",
+
[LogLevel.TRACE]: "#000000",
+
[LogLevel.DEBUG]: "#555555",
+
[LogLevel.INFO]: "#8686d9",
+
[LogLevel.WARN]: "#5454d1",
+
[LogLevel.ERROR]: "#FF0000"
+
};
+
+
const config = readConfig();
+
let maxLevel = LogLevel.INFO;
+
if (config.loggerLevel != null) {
+
const enumValue =
+
LogLevel[config.loggerLevel.toUpperCase() as keyof typeof LogLevel];
+
if (enumValue != null) {
+
maxLevel = enumValue;
+
}
+
}
+
+
export default class Logger {
+
private name: string;
+
+
constructor(name: string) {
+
this.name = name;
+
}
+
+
silly(...args: any[]) {
+
this.log(LogLevel.SILLY, args);
+
}
+
+
trace(...args: any[]) {
+
this.log(LogLevel.TRACE, args);
+
}
+
+
debug(...args: any[]) {
+
this.log(LogLevel.DEBUG, args);
+
}
+
+
info(...args: any[]) {
+
this.log(LogLevel.INFO, args);
+
}
+
+
warn(...args: any[]) {
+
this.log(LogLevel.WARN, args);
+
}
+
+
error(...args: any[]) {
+
this.log(LogLevel.ERROR, args);
+
}
+
+
log(level: LogLevel, obj: any[]) {
+
let args = [];
+
const logLevel = LogLevel[level].toUpperCase();
+
if (maxLevel > level) return;
+
+
if (MOONLIGHT_WEB_PRELOAD) {
+
args = [
+
`%c[${logLevel}]`,
+
`background-color: ${colors[level]}; color: #FFFFFF;`,
+
`[${this.name}]`,
+
...obj
+
];
+
} else {
+
args = [`[${logLevel}]`, `[${this.name}]`, ...obj];
+
}
+
+
switch (level) {
+
case LogLevel.SILLY:
+
case LogLevel.TRACE:
+
console.trace(...args);
+
break;
+
+
case LogLevel.DEBUG:
+
console.debug(...args);
+
break;
+
+
case LogLevel.INFO:
+
console.info(...args);
+
break;
+
+
case LogLevel.WARN:
+
console.warn(...args);
+
break;
+
+
case LogLevel.ERROR:
+
console.error(...args);
+
break;
+
}
+
}
+
}
+3
packages/core/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json"
+
}
+8
packages/injector/package.json
···
+
{
+
"name": "@moonlight-mod/injector",
+
"private": true,
+
"dependencies": {
+
"@moonlight-mod/types": "workspace:*",
+
"@moonlight-mod/core": "workspace:*"
+
}
+
}
+164
packages/injector/src/index.ts
···
+
import electron, {
+
BrowserWindowConstructorOptions,
+
BrowserWindow as ElectronBrowserWindow,
+
ipcMain,
+
app,
+
ipcRenderer
+
} from "electron";
+
import Module from "module";
+
import { constants } from "@moonlight-mod/types";
+
import { readConfig } from "@moonlight-mod/core/config";
+
import { getExtensions } from "@moonlight-mod/core/extension";
+
import Logger from "@moonlight-mod/core/util/logger";
+
import {
+
loadExtensions,
+
loadProcessedExtensions
+
} from "core/src/extension/loader";
+
import EventEmitter from "events";
+
+
let oldPreloadPath = "";
+
let corsAllow: string[] = [];
+
+
ipcMain.on(constants.ipcGetOldPreloadPath, (e) => {
+
e.returnValue = oldPreloadPath;
+
});
+
ipcMain.on(constants.ipcGetAppData, (e) => {
+
e.returnValue = app.getPath("appData");
+
});
+
ipcMain.handle(constants.ipcMessageBox, (_, opts) => {
+
electron.dialog.showMessageBoxSync(opts);
+
});
+
ipcMain.handle(constants.ipcSetCorsList, (_, list) => {
+
corsAllow = list;
+
});
+
+
function patchCsp(headers: Record<string, string[]>) {
+
const directives = [
+
"style-src",
+
"connect-src",
+
"img-src",
+
"font-src",
+
"media-src",
+
"worker-src",
+
"prefetch-src"
+
];
+
const values = ["*", "blob:", "data:", "'unsafe-inline'", "disclip:"];
+
+
const csp = "content-security-policy";
+
if (headers[csp] == null) return;
+
+
// This parsing is jank af lol
+
const entries = headers[csp][0]
+
.trim()
+
.split(";")
+
.map((x) => x.trim())
+
.filter((x) => x.length > 0)
+
.map((x) => x.split(" "))
+
.map((x) => [x[0], x.slice(1)]);
+
const parts = Object.fromEntries(entries);
+
+
for (const directive of directives) {
+
parts[directive] = values;
+
}
+
+
const stringified = Object.entries<string[]>(parts)
+
.map(([key, value]) => {
+
return `${key} ${value.join(" ")}`;
+
})
+
.join("; ");
+
headers[csp] = [stringified];
+
}
+
+
class BrowserWindow extends ElectronBrowserWindow {
+
constructor(opts: BrowserWindowConstructorOptions) {
+
oldPreloadPath = opts.webPreferences!.preload!;
+
opts.webPreferences!.preload = require.resolve("./node-preload.js");
+
+
super(opts);
+
this.webContents.session.webRequest.onHeadersReceived((details, cb) => {
+
if (details.responseHeaders != null) {
+
if (details.resourceType == "mainFrame") {
+
patchCsp(details.responseHeaders);
+
}
+
+
if (corsAllow.some((x) => details.url.startsWith(x))) {
+
details.responseHeaders["access-control-allow-origin"] = ["*"];
+
}
+
+
cb({ cancel: false, responseHeaders: details.responseHeaders });
+
}
+
});
+
}
+
}
+
+
export async function inject(asarPath: string) {
+
try {
+
const config = readConfig();
+
const extensions = getExtensions();
+
+
// Duplicated in node-preload... oops
+
function getConfig(ext: string) {
+
const val = config.extensions[ext];
+
if (val == null || typeof val === "boolean") return undefined;
+
return val.config;
+
}
+
+
global.moonlightHost = {
+
asarPath,
+
config,
+
events: new EventEmitter(),
+
extensions,
+
processedExtensions: {
+
extensions: [],
+
dependencyGraph: new Map()
+
},
+
+
getConfig,
+
getConfigOption: <T>(ext: string, name: string) => {
+
const config = getConfig(ext);
+
if (config == null) return undefined;
+
const option = config[name];
+
if (option == null) return undefined;
+
return option as T;
+
},
+
getLogger: (id: string) => {
+
return new Logger(id);
+
}
+
};
+
+
patchElectron();
+
+
global.moonlightHost.processedExtensions = await loadExtensions(extensions);
+
await loadProcessedExtensions(global.moonlightHost.processedExtensions);
+
} catch (e) {
+
console.error("Failed to inject", e);
+
}
+
+
require(asarPath);
+
}
+
+
function patchElectron() {
+
const electronClone = {};
+
+
for (const property of Object.getOwnPropertyNames(electron)) {
+
if (property === "BrowserWindow") {
+
Object.defineProperty(electronClone, property, {
+
get: () => BrowserWindow,
+
enumerable: true,
+
configurable: false
+
});
+
} else {
+
Object.defineProperty(
+
electronClone,
+
property,
+
Object.getOwnPropertyDescriptor(electron, property)!
+
);
+
}
+
}
+
+
// exports is a getter only on Windows, let's do some cursed shit instead
+
const electronPath = require.resolve("electron");
+
const cachedElectron = require.cache[electronPath]!;
+
require.cache[electronPath] = new Module(cachedElectron.id, require.main);
+
require.cache[electronPath]!.exports = electronClone;
+
}
+3
packages/injector/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json"
+
}
+8
packages/node-preload/package.json
···
+
{
+
"name": "@moonlight-mod/node-preload",
+
"private": true,
+
"dependencies": {
+
"@moonlight-mod/core": "workspace:*",
+
"@moonlight-mod/types": "workspace:*"
+
}
+
}
+93
packages/node-preload/src/index.ts
···
+
import { webFrame, ipcRenderer, contextBridge } from "electron";
+
import fs from "fs";
+
import path from "path";
+
+
import { readConfig, writeConfig } from "@moonlight-mod/core/config";
+
import { ProcessedExtensions, constants } from "@moonlight-mod/types";
+
import { getExtensions } from "@moonlight-mod/core/extension";
+
import { getExtensionsPath } from "@moonlight-mod/core/util/data";
+
import Logger from "@moonlight-mod/core/util/logger";
+
import {
+
loadExtensions,
+
loadProcessedExtensions
+
} from "core/src/extension/loader";
+
+
async function injectGlobals() {
+
const config = readConfig();
+
const extensions = getExtensions();
+
const processed = await loadExtensions(extensions);
+
+
function getConfig(ext: string) {
+
const val = config.extensions[ext];
+
if (val == null || typeof val === "boolean") return undefined;
+
return val.config;
+
}
+
+
global.moonlightNode = {
+
config,
+
extensions: getExtensions(),
+
processedExtensions: processed,
+
nativesCache: {},
+
+
getConfig,
+
getConfigOption: <T>(ext: string, name: string) => {
+
const config = getConfig(ext);
+
if (config == null) return undefined;
+
const option = config[name];
+
if (option == null) return undefined;
+
return option as T;
+
},
+
getNatives: (ext: string) => global.moonlightNode.nativesCache[ext],
+
getLogger: (id: string) => {
+
return new Logger(id);
+
},
+
+
getExtensionDir: (ext: string) => {
+
const extPath = getExtensionsPath();
+
return path.join(extPath, ext);
+
},
+
writeConfig
+
};
+
+
await loadProcessedExtensions(processed);
+
contextBridge.exposeInMainWorld("moonlightNode", moonlightNode);
+
+
const extCors = moonlightNode.processedExtensions.extensions
+
.map((x) => x.manifest.cors ?? [])
+
.flat();
+
+
for (const repo of moonlightNode.config.repositories) {
+
const url = new URL(repo);
+
url.pathname = "/";
+
extCors.push(url.toString());
+
}
+
+
ipcRenderer.invoke(constants.ipcSetCorsList, extCors);
+
}
+
+
async function loadPreload() {
+
const webPreloadPath = path.join(__dirname, "web-preload.js");
+
const webPreload = fs.readFileSync(webPreloadPath, "utf8");
+
await webFrame.executeJavaScript(webPreload);
+
}
+
+
async function init(oldPreloadPath: string) {
+
try {
+
await injectGlobals();
+
await loadPreload();
+
} catch (e) {
+
const message = e instanceof Error ? e.stack : e;
+
await ipcRenderer.invoke(constants.ipcMessageBox, {
+
title: "moonlight node-preload error",
+
message: message
+
});
+
}
+
+
// Let Discord start even if we fail
+
require(oldPreloadPath);
+
}
+
+
const oldPreloadPath: string = ipcRenderer.sendSync(
+
constants.ipcGetOldPreloadPath
+
);
+
init(oldPreloadPath);
+3
packages/node-preload/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json"
+
}
+16
packages/types/package.json
···
+
{
+
"name": "@moonlight-mod/types",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": "./src/index.ts",
+
"./*": "./src/*.ts"
+
},
+
"dependencies": {
+
"@types/flux": "^3.1.12",
+
"@types/node": "^20.6.2",
+
"@types/react": "^18.2.22",
+
"csstype": "^3.1.2",
+
"standalone-electron-types": "^1.0.0"
+
}
+
}
+78
packages/types/src/config.ts
···
+
export type Config = {
+
extensions: ConfigExtensions;
+
repositories: string[];
+
devSearchPaths?: string[];
+
loggerLevel?: string;
+
patchAll?: boolean;
+
};
+
+
export type ConfigExtensions =
+
| { [key: string]: boolean }
+
| { [key: string]: ConfigExtension };
+
+
export type ConfigExtension = {
+
enabled: boolean;
+
config?: Record<string, any>;
+
};
+
+
export enum ExtensionSettingType {
+
Boolean = "boolean",
+
Number = "number",
+
String = "string",
+
Select = "select",
+
List = "list",
+
Dictionary = "dictionary",
+
Custom = "custom"
+
}
+
+
export type BooleanSettingType = {
+
type: ExtensionSettingType.Boolean;
+
default?: boolean;
+
};
+
+
export type NumberSettingType = {
+
type: ExtensionSettingType.Number;
+
default?: number;
+
min?: number;
+
max?: number;
+
};
+
+
export type StringSettingType = {
+
type: ExtensionSettingType.String;
+
default?: string;
+
};
+
+
export type SelectSettingType = {
+
type: ExtensionSettingType.Select;
+
options: string[];
+
default?: string;
+
};
+
+
export type ListSettingType = {
+
type: ExtensionSettingType.List;
+
options?: string[];
+
default?: string[];
+
};
+
+
export type DictionarySettingType = {
+
type: ExtensionSettingType.Dictionary;
+
default?: Record<string, string>;
+
};
+
+
export type CustomSettingType = {
+
type: ExtensionSettingType.Custom;
+
default?: any;
+
};
+
+
export type ExtensionSettingsManifest = {
+
displayName?: string;
+
description?: string;
+
} & (
+
| BooleanSettingType
+
| NumberSettingType
+
| StringSettingType
+
| SelectSettingType
+
| ListSettingType
+
| DictionarySettingType
+
| CustomSettingType
+
);
+9
packages/types/src/constants.ts
···
+
export const extensionsDir = "extensions";
+
export const distDir = "dist";
+
export const coreExtensionsDir = "core-extensions";
+
export const repoUrlFile = ".moonlight-repo-url";
+
+
export const ipcGetOldPreloadPath = "_moonlight_getOldPreloadPath";
+
export const ipcGetAppData = "_moonlight_getAppData";
+
export const ipcMessageBox = "_moonlight_messageBox";
+
export const ipcSetCorsList = "_moonlight_setCorsList";
+66
packages/types/src/coreExtensions.ts
···
+
import { FluxDefault, Store } from "./discord/common/Flux";
+
import WebpackRequire from "./discord/require";
+
import { WebpackModuleFunc } from "./discord/webpack";
+
import { CommonComponents as CommonComponents_ } from "./coreExtensions/components";
+
import { Dispatcher } from "flux";
+
import React from "react";
+
+
export type Spacepack = {
+
inspect: (module: number | string) => WebpackModuleFunc | null;
+
findByCode: (...args: (string | RegExp)[]) => any[];
+
findByExports: (...args: string[]) => any[];
+
require: typeof WebpackRequire;
+
modules: Record<string, WebpackModuleFunc>;
+
cache: Record<string, any>;
+
findObjectFromKey: (exports: Record<string, any>, key: string) => any | null;
+
findObjectFromValue: (exports: Record<string, any>, value: any) => any | null;
+
findObjectFromKeyValuePair: (
+
exports: Record<string, any>,
+
key: string,
+
value: any
+
) => any | null;
+
findFunctionByStrings: (
+
exports: Record<string, any>,
+
...strings: (string | RegExp)[]
+
) => Function | null;
+
};
+
+
export type NoticeProps = {
+
stores: Store<any>[];
+
element: React.FunctionComponent;
+
};
+
+
export type SettingsSection =
+
| { section: "DIVIDER"; pos: number }
+
| { section: "HEADER"; label: string; pos: number }
+
| {
+
section: string;
+
label: string;
+
color: string | null;
+
element: React.FunctionComponent;
+
pos: number;
+
notice?: NoticeProps;
+
_moonlight_submenu?: () => any;
+
};
+
+
export type Settings = {
+
ourSections: SettingsSection[];
+
+
addSection: (
+
section: string,
+
label: string,
+
element: React.FunctionComponent,
+
color?: string | null,
+
pos?: number,
+
notice?: NoticeProps
+
) => void;
+
+
addDivider: (pos: number | null) => void;
+
addHeader: (label: string, pos: number | null) => void;
+
_mutateSections: (sections: SettingsSection[]) => SettingsSection[];
+
};
+
+
export type CommonReact = typeof import("react");
+
export type CommonFlux = FluxDefault;
+
export type CommonComponents = CommonComponents_; // lol
+
export type CommonFluxDispatcher = Dispatcher<any>;
+356
packages/types/src/coreExtensions/components.ts
···
+
import type {
+
Component,
+
Ref,
+
PropsWithChildren,
+
PropsWithoutRef,
+
CSSProperties,
+
ReactNode,
+
MouseEvent,
+
KeyboardEvent,
+
ReactElement,
+
ComponentClass,
+
ComponentType,
+
MouseEventHandler,
+
KeyboardEventHandler
+
} from "react";
+
import * as CSS from "csstype";
+
+
export enum TextInputSizes {
+
DEFAULT = "inputDefault",
+
MINI = "inputMini"
+
}
+
+
interface TextInput
+
extends ComponentClass<
+
PropsWithoutRef<{
+
value?: string;
+
name?: string;
+
className?: string;
+
inputClassName?: string;
+
inputPrefix?: string;
+
disabled?: boolean;
+
size?: TextInputSizes;
+
editable?: boolean;
+
inputRef?: Ref<any>;
+
prefixElement?: Component;
+
focusProps?: PropsWithoutRef<any>;
+
error?: string;
+
minLength?: number;
+
maxLength?: number;
+
onChange?: (value: string, name: string) => void;
+
onFocus?: (event: any, name: string) => void;
+
onBlur?: (event: any, name: string) => void;
+
}>
+
> {
+
Sizes: TextInputSizes;
+
}
+
+
export enum FormTextTypes {
+
DEFAULT = "default",
+
DESCRIPTION = "description",
+
ERROR = "error",
+
INPUT_PLACEHOLDER = "placeholder",
+
LABEL_BOLD = "labelBold",
+
LABEL_DESCRIPTOR = "labelDescriptor",
+
LABEL_SELECTED = "labelSelected",
+
SUCCESS = "success"
+
}
+
+
interface FormText
+
extends Component<
+
PropsWithChildren<{
+
type?: FormTextTypes;
+
className?: string;
+
disabled?: boolean;
+
selectable?: boolean;
+
style?: CSSProperties;
+
}>
+
> {
+
Types: FormTextTypes;
+
}
+
+
declare enum SliderMarkerPosition {
+
ABOVE,
+
BELOW
+
}
+
+
declare enum ButtonLooks {
+
FILLED = "lookFilled",
+
INVERTED = "lookInverted",
+
OUTLINED = "lookOutlined",
+
LINK = "lookLink",
+
BLANK = "lookBlank"
+
}
+
declare enum ButtonColors {
+
BRAND = "colorBrand",
+
RED = "colorRed",
+
GREEN = "colorGreen",
+
YELLOW = "colorYellow",
+
PRIMARY = "colorPrimary",
+
LINK = "colorLink",
+
WHITE = "colorWhite",
+
BLACK = "colorBlack",
+
TRANSPARENT = "colorTransparent",
+
BRAND_NEW = "colorBrandNew",
+
CUSTOM = ""
+
}
+
declare enum ButtonBorderColors {
+
BRAND = "borderBrand",
+
RED = "borderRed",
+
GREEN = "borderGreen",
+
YELLOW = "borderYellow",
+
PRIMARY = "borderPrimary",
+
LINK = "borderLink",
+
WHITE = "borderWhite",
+
BLACK = "borderBlack",
+
TRANSPARENT = "borderTransparent",
+
BRAND_NEW = "borderBrandNew"
+
}
+
declare enum ButtonHovers {
+
DEFAULT = "",
+
BRAND = "hoverBrand",
+
RED = "hoverRed",
+
GREEN = "hoverGreen",
+
YELLOW = "hoverYellow",
+
PRIMARY = "hoverPrimary",
+
LINK = "hoverLink",
+
WHITE = "hoverWhite",
+
BLACK = "hoverBlack",
+
TRANSPARENT = "hoverTransparent"
+
}
+
declare enum ButtonSizes {
+
NONE = "",
+
TINY = "sizeTiny",
+
SMALL = "sizeSmall",
+
MEDIUM = "sizeMedium",
+
LARGE = "sizeLarge",
+
XLARGE = "sizeXlarge",
+
MIN = "sizeMin",
+
MAX = "sizeMax",
+
ICON = "sizeIcon"
+
}
+
+
type Button = ComponentType<
+
PropsWithChildren<{
+
look?: ButtonLooks;
+
color?: ButtonColors;
+
borderColor?: ButtonBorderColors;
+
hover?: ButtonHovers;
+
size?: ButtonSizes;
+
fullWidth?: boolean;
+
grow?: boolean;
+
disabled?: boolean;
+
submitting?: boolean;
+
type?: string;
+
style?: CSSProperties;
+
wrapperClassName?: string;
+
className?: string;
+
innerClassName?: string;
+
onClick?: MouseEventHandler;
+
onDoubleClick?: MouseEventHandler;
+
onMouseDown?: MouseEventHandler;
+
onMouseUp?: MouseEventHandler;
+
onMouseEnter?: MouseEventHandler;
+
onMouseLeave?: MouseEventHandler;
+
onKeyDown?: KeyboardEventHandler;
+
rel?: any;
+
buttonRef?: Ref<any>;
+
focusProps?: PropsWithChildren<any>;
+
"aria-label"?: string;
+
submittingStartedLabel?: string;
+
submittingFinishedLabel?: string;
+
}>
+
> & {
+
Looks: typeof ButtonLooks;
+
Colors: typeof ButtonColors;
+
BorderColors: typeof ButtonBorderColors;
+
Hovers: typeof ButtonHovers;
+
Sizes: typeof ButtonSizes;
+
};
+
+
export enum FlexDirection {
+
VERTICAL = "vertical",
+
HORIZONTAL = "horizontal",
+
HORIZONTAL_REVERSE = "horizontalReverse"
+
}
+
+
declare enum FlexAlign {
+
START = "alignStart",
+
END = "alignEnd",
+
CENTER = "alignCenter",
+
STRETCH = "alignStretch",
+
BASELINE = "alignBaseline"
+
}
+
declare enum FlexJustify {
+
START = "justifyStart",
+
END = "justifyEnd",
+
CENTER = "justifyCenter",
+
BETWEEN = "justifyBetween",
+
AROUND = "justifyAround"
+
}
+
declare enum FlexWrap {
+
NO_WRAP = "noWrap",
+
WRAP = "wrap",
+
WRAP_REVERSE = "wrapReverse"
+
}
+
interface Flex
+
extends ComponentClass<
+
PropsWithChildren<{
+
className?: string;
+
direction?: FlexDirection;
+
justify?: FlexJustify;
+
align?: FlexAlign;
+
wrap?: FlexWrap;
+
shrink?: CSS.Property.FlexShrink;
+
grow?: CSS.Property.FlexGrow;
+
basis?: CSS.Property.FlexBasis;
+
style?: CSSProperties;
+
}>
+
> {
+
Direction: typeof FlexDirection;
+
Align: typeof FlexAlign;
+
Justify: typeof FlexJustify;
+
Wrap: typeof FlexWrap;
+
Child: Component<
+
PropsWithChildren<{
+
className?: string;
+
shrink?: CSS.Property.FlexShrink;
+
grow?: CSS.Property.FlexGrow;
+
basis?: CSS.Property.FlexBasis;
+
style?: CSSProperties;
+
wrap?: boolean;
+
}>
+
>;
+
}
+
+
// TODO: wtaf is up with react types not working in jsx
+
export type CommonComponents = {
+
Clickable: Component<
+
PropsWithChildren<{
+
onClick?: () => void;
+
href?: any;
+
onKeyPress?: () => void;
+
ignoreKeyPress?: boolean;
+
innerRef?: Ref<any>;
+
focusProps?: any;
+
tag?: string | Component;
+
role?: any;
+
tabIndex?: any;
+
className?: string;
+
}>
+
>;
+
TextInput: TextInput;
+
Form: {
+
Section: Component<
+
PropsWithChildren<{
+
className?: string;
+
titleClassName?: string;
+
title?: ReactNode;
+
icon?: ReactNode;
+
disabled?: boolean;
+
htmlFor?: any;
+
tag?: string;
+
}>
+
>;
+
Text: FormText;
+
Title: Component<
+
PropsWithChildren<{
+
tag?: string;
+
className?: string;
+
faded?: boolean;
+
disabled?: boolean;
+
required?: boolean;
+
error?: string;
+
}>
+
>;
+
};
+
Slider: ComponentClass<
+
PropsWithChildren<{
+
disabled?: boolean;
+
stickToMarkers?: boolean;
+
className?: string;
+
barStyles?: CSSProperties;
+
fillStyles?: CSSProperties;
+
mini?: boolean;
+
hideBubble?: boolean;
+
initialValue?: number;
+
orientation?: "horizontal" | "vertical";
+
onValueRender?: (value: number) => string;
+
renderMarker?: (marker: number) => ReactNode;
+
getAriaValueText?: (value: number) => string;
+
barClassName?: string;
+
grabberClassName?: string;
+
grabberStyles?: CSSProperties;
+
markerPosition?: SliderMarkerPosition;
+
"aria-hidden"?: "true" | "false";
+
"aria-label"?: string;
+
"aria-labelledby"?: string;
+
"aria-describedby"?: string;
+
minValue?: number;
+
maxValue?: number;
+
asValueChanges?: (value: number) => void;
+
onValueChange?: (value: number) => void;
+
keyboardStep?: number;
+
}>
+
>;
+
FormSwitch: ComponentClass<PropsWithChildren<any>>;
+
Switch: ComponentClass<PropsWithChildren<any>>;
+
Button: Button;
+
SmallSlider: Component;
+
Avatar: Component;
+
Scroller: Component;
+
Text: ComponentClass<PropsWithChildren<any>>;
+
LegacyText: Component;
+
Flex: Flex;
+
Card: ComponentClass<PropsWithChildren<any>>;
+
CardClasses: {
+
card: string;
+
cardHeader: string;
+
};
+
ControlClasses: {
+
container: string;
+
control: string;
+
disabled: string;
+
dividerDefault: string;
+
labelRow: string;
+
note: string;
+
title: string;
+
titleDefault: string;
+
titleMini: string;
+
};
+
MarkdownParser: {
+
parse: (text: string) => ReactElement;
+
};
+
SettingsNotice: React.ComponentType<{
+
submitting: boolean;
+
onReset: () => void;
+
onSave: () => void;
+
}>;
+
TabBar: React.ComponentType<any> & {
+
Item: React.ComponentType<any>;
+
};
+
SingleSelect: React.ComponentType<{
+
autofocus?: boolean;
+
clearable?: boolean;
+
value?: string;
+
options?: {
+
value: string;
+
label: string;
+
}[];
+
onChange?: (value: string) => void;
+
}>;
+
Select: React.ComponentType<{
+
autofocus?: boolean;
+
clearable?: boolean;
+
value?: string[];
+
options?: {
+
value: string;
+
label: string;
+
}[];
+
onChange?: (value: string[]) => void;
+
}>;
+
+
// TODO
+
useVariableSelect: any;
+
multiSelect: any;
+
};
+57
packages/types/src/discord/common/Flux.ts
···
+
/*
+
It seems like Discord maintains their own version of Flux that doesn't match
+
the types on NPM. This is a heavy work in progress - if you encounter rough
+
edges, please contribute!
+
*/
+
+
import { DependencyList } from "react";
+
import { Store as FluxStore } from "flux/utils";
+
import { Dispatcher as FluxDispatcher } from "flux";
+
import { ComponentConstructor } from "flux/lib/FluxContainer";
+
+
export declare abstract class Store<T> extends FluxStore<T> {
+
static getAll: () => Store<any>[];
+
getName: () => string;
+
emitChange: () => void;
+
}
+
+
interface ConnectStores {
+
<T>(
+
stores: Store<any>[],
+
callback: T,
+
context?: any
+
): ComponentConstructor<T>;
+
}
+
+
export type FluxDefault = {
+
DeviceSettingsStore: any; // TODO
+
Emitter: any; // @types/fbemitter
+
OfflineCacheStore: any; // TODO
+
PersistedStore: any; // TODO
+
Store: typeof Store;
+
Dispatcher: typeof FluxDispatcher;
+
connectStores: ConnectStores;
+
initialize: () => void;
+
initialized: Promise<boolean>;
+
destroy: () => void;
+
useStateFromStores: UseStateFromStores;
+
useStateFromStoresArray: UseStateFromStoresArray;
+
useStateFromStoresObject: UseStateFromStoresObject;
+
};
+
+
interface UseStateFromStores {
+
<T>(
+
stores: Store<any>[],
+
callback: () => T,
+
deps?: DependencyList,
+
shouldUpdate?: (oldState: T, newState: T) => boolean
+
): T;
+
}
+
+
interface UseStateFromStoresArray {
+
<T>(stores: Store<any>[], callback: () => T, deps?: DependencyList): T;
+
}
+
+
interface UseStateFromStoresObject {
+
<T>(stores: Store<any>[], callback: () => T, deps?: DependencyList): T;
+
}
+3
packages/types/src/discord/index.ts
···
+
export type Snowflake = `${number}`;
+
+
export * from "./webpack";
+20
packages/types/src/discord/require.ts
···
+
import {
+
Spacepack,
+
CommonReact,
+
CommonFlux,
+
Settings,
+
CommonComponents,
+
CommonFluxDispatcher
+
} from "../coreExtensions";
+
+
declare function WebpackRequire(id: string): any;
+
declare function WebpackRequire(id: "spacepack_spacepack"): Spacepack;
+
declare function WebpackRequire(id: "common_react"): CommonReact;
+
declare function WebpackRequire(id: "common_flux"): CommonFlux;
+
declare function WebpackRequire(
+
id: "common_fluxDispatcher"
+
): CommonFluxDispatcher;
+
declare function WebpackRequire(id: "settings_settings"): Settings;
+
declare function WebpackRequire(id: "common_components"): CommonComponents;
+
+
export default WebpackRequire;
+32
packages/types/src/discord/webpack.ts
···
+
import WebpackRequire from "./require";
+
+
export type WebpackRequireType = typeof WebpackRequire & {
+
c: Record<string, WebpackModule>;
+
m: Record<string, WebpackModuleFunc>;
+
};
+
+
export type WebpackModule = {
+
id: string | number;
+
loaded: boolean;
+
exports: any;
+
};
+
+
export type WebpackModuleFunc = ((
+
module: any,
+
exports: any,
+
require: WebpackRequireType
+
) => void) & {
+
__moonlight?: boolean;
+
};
+
+
export type WebpackJsonpEntry = [
+
number[],
+
{ [id: string]: WebpackModuleFunc },
+
(require: WebpackRequireType) => any
+
];
+
+
export type WebpackJsonp = WebpackJsonpEntry[] & {
+
push: {
+
__moonlight?: boolean;
+
};
+
};
+125
packages/types/src/extension.ts
···
+
import { ExtensionSettingsManifest } from "./config";
+
import { Snowflake } from "./discord";
+
import { WebpackModuleFunc } from "./discord/webpack";
+
+
export enum ExtensionTag {
+
Accessibility = "accessibility",
+
Appearance = "appearance",
+
Chat = "chat",
+
Commands = "commands",
+
ContextMenu = "contextMenu",
+
DangerZone = "dangerZone",
+
Development = "development",
+
Fixes = "fixes",
+
Fun = "fun",
+
Markdown = "markdown",
+
Voice = "voice",
+
Privacy = "privacy",
+
Profiles = "profiles",
+
QualityOfLife = "qol",
+
Library = "library"
+
}
+
+
export type ExtensionAuthor =
+
| string
+
| {
+
name: string;
+
id?: Snowflake;
+
};
+
+
export type ExtensionManifest = {
+
id: string;
+
version?: string;
+
+
meta?: {
+
name?: string;
+
tagline?: string;
+
description?: string;
+
authors?: ExtensionAuthor[];
+
deprecated?: boolean;
+
tags?: ExtensionTag[];
+
source?: string;
+
};
+
+
dependencies?: string[];
+
suggested?: string[];
+
incompatible?: string[]; // TODO: implement
+
+
settings?: Record<string, ExtensionSettingsManifest>;
+
cors?: string[];
+
};
+
+
export enum ExtensionLoadSource {
+
Developer,
+
Core,
+
Normal
+
}
+
+
export type DetectedExtension = {
+
id: string;
+
manifest: ExtensionManifest;
+
source: { type: ExtensionLoadSource; url?: string };
+
scripts: {
+
web?: string;
+
webPath?: string;
+
nodePath?: string;
+
hostPath?: string;
+
};
+
};
+
+
export type ProcessedExtensions = {
+
extensions: DetectedExtension[];
+
dependencyGraph: Map<string, Set<string> | null>;
+
};
+
+
export type PatchMatch = string | RegExp;
+
export type PatchReplaceFn = (substring: string, ...args: string[]) => string;
+
export type PatchReplaceModule = (orig: string) => WebpackModuleFunc;
+
+
export enum PatchReplaceType {
+
Normal,
+
Module
+
}
+
+
export type PatchReplace =
+
| {
+
type?: PatchReplaceType.Normal;
+
match: PatchMatch;
+
replacement: string | PatchReplaceFn;
+
}
+
| {
+
type: PatchReplaceType.Module;
+
replacement: PatchReplaceModule;
+
};
+
+
export type Patch = {
+
find: PatchMatch;
+
replace: PatchReplace | PatchReplace[];
+
prerequisite?: () => boolean;
+
};
+
+
export type ExplicitExtensionDependency = {
+
ext: string;
+
id: string;
+
};
+
+
export type ExtensionDependency = string | RegExp | ExplicitExtensionDependency;
+
+
export type ExtensionWebpackModule = {
+
entrypoint?: boolean;
+
dependencies?: ExtensionDependency[];
+
run: WebpackModuleFunc;
+
};
+
+
export type ExtensionWebExports = {
+
patches?: Patch[];
+
webpackModules?: Record<string, ExtensionWebpackModule>;
+
};
+
+
export type IdentifiedPatch = Patch & {
+
ext: string;
+
id: number;
+
};
+
+
export type IdentifiedWebpackModule = ExtensionWebpackModule &
+
ExplicitExtensionDependency;
+51
packages/types/src/globals.ts
···
+
import { Logger } from "./logger";
+
import { Config, ConfigExtension } from "./config";
+
import {
+
DetectedExtension,
+
IdentifiedPatch,
+
ProcessedExtensions
+
} from "./extension";
+
import EventEmitter from "events";
+
+
export type MoonlightHost = {
+
asarPath: string;
+
config: Config;
+
events: EventEmitter;
+
extensions: DetectedExtension[];
+
processedExtensions: ProcessedExtensions;
+
+
getConfig: (ext: string) => ConfigExtension["config"];
+
getConfigOption: <T>(ext: string, name: string) => T | undefined;
+
getLogger: (id: string) => Logger;
+
};
+
+
export type MoonlightNode = {
+
config: Config;
+
extensions: DetectedExtension[];
+
processedExtensions: ProcessedExtensions;
+
nativesCache: Record<string, any>;
+
+
getConfig: (ext: string) => ConfigExtension["config"];
+
getConfigOption: <T>(ext: string, name: string) => T | undefined;
+
getNatives: (ext: string) => any | undefined;
+
getLogger: (id: string) => Logger;
+
+
getExtensionDir: (ext: string) => string;
+
writeConfig: (config: Config) => void;
+
};
+
+
export type MoonlightWeb = {
+
unpatched: Set<IdentifiedPatch>;
+
enabledExtensions: Set<string>;
+
+
getConfig: (ext: string) => ConfigExtension["config"];
+
getConfigOption: <T>(ext: string, name: string) => T | undefined;
+
getNatives: (ext: string) => any | undefined;
+
getLogger: (id: string) => Logger;
+
};
+
+
export enum MoonlightEnv {
+
Injector = "injector",
+
NodePreload = "node-preload",
+
WebPreload = "web-preload"
+
}
+30
packages/types/src/index.ts
···
+
/// <reference types="node" />
+
/// <reference types="standalone-electron-types" />
+
/// <reference types="react" />
+
/// <reference types="flux" />
+
+
import {
+
MoonlightEnv,
+
MoonlightHost,
+
MoonlightNode,
+
MoonlightWeb
+
} from "./globals";
+
+
export * from "./discord";
+
export * from "./config";
+
export * from "./extension";
+
export * from "./globals";
+
export * from "./logger";
+
export * as constants from "./constants";
+
+
declare global {
+
const MOONLIGHT_ENV: MoonlightEnv;
+
const MOONLIGHT_PROD: boolean;
+
const MOONLIGHT_INJECTOR: boolean;
+
const MOONLIGHT_NODE_PRELOAD: boolean;
+
const MOONLIGHT_WEB_PRELOAD: boolean;
+
+
var moonlightHost: MoonlightHost;
+
var moonlightNode: MoonlightNode;
+
var moonlight: MoonlightWeb;
+
}
+20
packages/types/src/logger.ts
···
+
export enum LogLevel {
+
SILLY,
+
TRACE,
+
DEBUG,
+
INFO,
+
WARN,
+
ERROR
+
}
+
+
type LogFunc = (...args: any[]) => void;
+
+
export type Logger = {
+
silly: LogFunc;
+
trace: LogFunc;
+
debug: LogFunc;
+
info: LogFunc;
+
warn: LogFunc;
+
error: LogFunc;
+
log: (level: LogLevel, args: any[]) => void;
+
};
+8
packages/types/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"declaration": true
+
},
+
"include": ["./src/**/*", "src/index.ts"]
+
}
+7
packages/web-preload/package.json
···
+
{
+
"name": "@moonlight-mod/web-preload",
+
"private": true,
+
"dependencies": {
+
"@moonlight-mod/core": "workspace:*"
+
}
+
}
+29
packages/web-preload/src/index.ts
···
+
import {
+
loadExtensions,
+
loadProcessedExtensions
+
} from "@moonlight-mod/core/extension/loader";
+
import { installWebpackPatcher } from "@moonlight-mod/core/patch";
+
import Logger from "@moonlight-mod/core/util/logger";
+
+
(async () => {
+
const logger = new Logger("web-preload");
+
+
window.moonlight = {
+
unpatched: new Set(),
+
enabledExtensions: new Set(),
+
+
getConfig: moonlightNode.getConfig.bind(moonlightNode),
+
getConfigOption: moonlightNode.getConfigOption.bind(moonlightNode),
+
getNatives: moonlightNode.getNatives.bind(moonlightNode),
+
getLogger: (id: string) => {
+
return new Logger(id);
+
}
+
};
+
+
try {
+
await loadProcessedExtensions(moonlightNode.processedExtensions);
+
await installWebpackPatcher();
+
} catch (e) {
+
logger.error("Error setting up web-preload", e);
+
}
+
})();
+3
packages/web-preload/tsconfig.json
···
+
{
+
"extends": "../../tsconfig.json"
+
}
+654
pnpm-lock.yaml
···
+
lockfileVersion: '6.0'
+
+
settings:
+
autoInstallPeers: true
+
excludeLinksFromLockfile: false
+
+
importers:
+
+
.:
+
devDependencies:
+
esbuild:
+
specifier: ^0.19.3
+
version: 0.19.3
+
esbuild-copy-static-files:
+
specifier: ^0.1.0
+
version: 0.1.0
+
+
packages/core:
+
dependencies:
+
'@moonlight-mod/types':
+
specifier: workspace:*
+
version: link:../types
+
glob:
+
specifier: ^10.3.4
+
version: 10.3.4
+
+
packages/core-extensions:
+
dependencies:
+
'@electron/asar':
+
specifier: ^3.2.5
+
version: 3.2.5
+
'@moonlight-mod/types':
+
specifier: workspace:*
+
version: link:../types
+
+
packages/injector:
+
dependencies:
+
'@moonlight-mod/core':
+
specifier: workspace:*
+
version: link:../core
+
'@moonlight-mod/types':
+
specifier: workspace:*
+
version: link:../types
+
+
packages/node-preload:
+
dependencies:
+
'@moonlight-mod/core':
+
specifier: workspace:*
+
version: link:../core
+
'@moonlight-mod/types':
+
specifier: workspace:*
+
version: link:../types
+
+
packages/types:
+
dependencies:
+
'@types/flux':
+
specifier: ^3.1.12
+
version: 3.1.12
+
'@types/node':
+
specifier: ^20.6.2
+
version: 20.6.2
+
'@types/react':
+
specifier: ^18.2.22
+
version: 18.2.22
+
csstype:
+
specifier: ^3.1.2
+
version: 3.1.2
+
standalone-electron-types:
+
specifier: ^1.0.0
+
version: 1.0.0
+
+
packages/web-preload:
+
dependencies:
+
'@moonlight-mod/core':
+
specifier: workspace:*
+
version: link:../core
+
+
packages:
+
+
/@electron/asar@3.2.5:
+
resolution: {integrity: sha512-Ypahc2ElTj9YOrFvUHuoXv5Z/V1nPA5enlhmQapc578m/HZBHKTbqhoL5JZQjje2+/6Ti5AHh7Gj1/haeJa63Q==}
+
engines: {node: '>=10.12.0'}
+
hasBin: true
+
dependencies:
+
commander: 5.1.0
+
glob: 7.2.3
+
minimatch: 3.1.2
+
dev: false
+
+
/@esbuild/android-arm64@0.19.3:
+
resolution: {integrity: sha512-w+Akc0vv5leog550kjJV9Ru+MXMR2VuMrui3C61mnysim0gkFCPOUTAfzTP0qX+HpN9Syu3YA3p1hf3EPqObRw==}
+
engines: {node: '>=12'}
+
cpu: [arm64]
+
os: [android]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/android-arm@0.19.3:
+
resolution: {integrity: sha512-Lemgw4io4VZl9GHJmjiBGzQ7ONXRfRPHcUEerndjwiSkbxzrpq0Uggku5MxxrXdwJ+pTj1qyw4jwTu7hkPsgIA==}
+
engines: {node: '>=12'}
+
cpu: [arm]
+
os: [android]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/android-x64@0.19.3:
+
resolution: {integrity: sha512-FKQJKkK5MXcBHoNZMDNUAg1+WcZlV/cuXrWCoGF/TvdRiYS4znA0m5Il5idUwfxrE20bG/vU1Cr5e1AD6IEIjQ==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [android]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/darwin-arm64@0.19.3:
+
resolution: {integrity: sha512-kw7e3FXU+VsJSSSl2nMKvACYlwtvZB8RUIeVShIEY6PVnuZ3c9+L9lWB2nWeeKWNNYDdtL19foCQ0ZyUL7nqGw==}
+
engines: {node: '>=12'}
+
cpu: [arm64]
+
os: [darwin]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/darwin-x64@0.19.3:
+
resolution: {integrity: sha512-tPfZiwF9rO0jW6Jh9ipi58N5ZLoSjdxXeSrAYypy4psA2Yl1dAMhM71KxVfmjZhJmxRjSnb29YlRXXhh3GqzYw==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [darwin]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/freebsd-arm64@0.19.3:
+
resolution: {integrity: sha512-ERDyjOgYeKe0Vrlr1iLrqTByB026YLPzTytDTz1DRCYM+JI92Dw2dbpRHYmdqn6VBnQ9Bor6J8ZlNwdZdxjlSg==}
+
engines: {node: '>=12'}
+
cpu: [arm64]
+
os: [freebsd]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/freebsd-x64@0.19.3:
+
resolution: {integrity: sha512-nXesBZ2Ad1qL+Rm3crN7NmEVJ5uvfLFPLJev3x1j3feCQXfAhoYrojC681RhpdOph8NsvKBBwpYZHR7W0ifTTA==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [freebsd]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-arm64@0.19.3:
+
resolution: {integrity: sha512-qXvYKmXj8GcJgWq3aGvxL/JG1ZM3UR272SdPU4QSTzD0eymrM7leiZH77pvY3UetCy0k1xuXZ+VPvoJNdtrsWQ==}
+
engines: {node: '>=12'}
+
cpu: [arm64]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-arm@0.19.3:
+
resolution: {integrity: sha512-zr48Cg/8zkzZCzDHNxXO/89bf9e+r4HtzNUPoz4GmgAkF1gFAFmfgOdCbR8zMbzFDGb1FqBBhdXUpcTQRYS1cQ==}
+
engines: {node: '>=12'}
+
cpu: [arm]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-ia32@0.19.3:
+
resolution: {integrity: sha512-7XlCKCA0nWcbvYpusARWkFjRQNWNGlt45S+Q18UeS///K6Aw8bB2FKYe9mhVWy/XLShvCweOLZPrnMswIaDXQA==}
+
engines: {node: '>=12'}
+
cpu: [ia32]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-loong64@0.19.3:
+
resolution: {integrity: sha512-qGTgjweER5xqweiWtUIDl9OKz338EQqCwbS9c2Bh5jgEH19xQ1yhgGPNesugmDFq+UUSDtWgZ264st26b3de8A==}
+
engines: {node: '>=12'}
+
cpu: [loong64]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-mips64el@0.19.3:
+
resolution: {integrity: sha512-gy1bFskwEyxVMFRNYSvBauDIWNggD6pyxUksc0MV9UOBD138dKTzr8XnM2R4mBsHwVzeuIH8X5JhmNs2Pzrx+A==}
+
engines: {node: '>=12'}
+
cpu: [mips64el]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-ppc64@0.19.3:
+
resolution: {integrity: sha512-UrYLFu62x1MmmIe85rpR3qou92wB9lEXluwMB/STDzPF9k8mi/9UvNsG07Tt9AqwPQXluMQ6bZbTzYt01+Ue5g==}
+
engines: {node: '>=12'}
+
cpu: [ppc64]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-riscv64@0.19.3:
+
resolution: {integrity: sha512-9E73TfyMCbE+1AwFOg3glnzZ5fBAFK4aawssvuMgCRqCYzE0ylVxxzjEfut8xjmKkR320BEoMui4o/t9KA96gA==}
+
engines: {node: '>=12'}
+
cpu: [riscv64]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-s390x@0.19.3:
+
resolution: {integrity: sha512-LlmsbuBdm1/D66TJ3HW6URY8wO6IlYHf+ChOUz8SUAjVTuaisfuwCOAgcxo3Zsu3BZGxmI7yt//yGOxV+lHcEA==}
+
engines: {node: '>=12'}
+
cpu: [s390x]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/linux-x64@0.19.3:
+
resolution: {integrity: sha512-ogV0+GwEmvwg/8ZbsyfkYGaLACBQWDvO0Kkh8LKBGKj9Ru8VM39zssrnu9Sxn1wbapA2qNS6BiLdwJZGouyCwQ==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [linux]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/netbsd-x64@0.19.3:
+
resolution: {integrity: sha512-o1jLNe4uzQv2DKXMlmEzf66Wd8MoIhLNO2nlQBHLtWyh2MitDG7sMpfCO3NTcoTMuqHjfufgUQDFRI5C+xsXQw==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [netbsd]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/openbsd-x64@0.19.3:
+
resolution: {integrity: sha512-AZJCnr5CZgZOdhouLcfRdnk9Zv6HbaBxjcyhq0StNcvAdVZJSKIdOiPB9az2zc06ywl0ePYJz60CjdKsQacp5Q==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [openbsd]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/sunos-x64@0.19.3:
+
resolution: {integrity: sha512-Acsujgeqg9InR4glTRvLKGZ+1HMtDm94ehTIHKhJjFpgVzZG9/pIcWW/HA/DoMfEyXmANLDuDZ2sNrWcjq1lxw==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [sunos]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/win32-arm64@0.19.3:
+
resolution: {integrity: sha512-FSrAfjVVy7TifFgYgliiJOyYynhQmqgPj15pzLyJk8BUsnlWNwP/IAy6GAiB1LqtoivowRgidZsfpoYLZH586A==}
+
engines: {node: '>=12'}
+
cpu: [arm64]
+
os: [win32]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/win32-ia32@0.19.3:
+
resolution: {integrity: sha512-xTScXYi12xLOWZ/sc5RBmMN99BcXp/eEf7scUC0oeiRoiT5Vvo9AycuqCp+xdpDyAU+LkrCqEpUS9fCSZF8J3Q==}
+
engines: {node: '>=12'}
+
cpu: [ia32]
+
os: [win32]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@esbuild/win32-x64@0.19.3:
+
resolution: {integrity: sha512-FbUN+0ZRXsypPyWE2IwIkVjDkDnJoMJARWOcFZn4KPPli+QnKqF0z1anvfaYe3ev5HFCpRDLLBDHyOALLppWHw==}
+
engines: {node: '>=12'}
+
cpu: [x64]
+
os: [win32]
+
requiresBuild: true
+
dev: true
+
optional: true
+
+
/@isaacs/cliui@8.0.2:
+
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+
engines: {node: '>=12'}
+
dependencies:
+
string-width: 5.1.2
+
string-width-cjs: /string-width@4.2.3
+
strip-ansi: 7.1.0
+
strip-ansi-cjs: /strip-ansi@6.0.1
+
wrap-ansi: 8.1.0
+
wrap-ansi-cjs: /wrap-ansi@7.0.0
+
dev: false
+
+
/@pkgjs/parseargs@0.11.0:
+
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+
engines: {node: '>=14'}
+
requiresBuild: true
+
dev: false
+
optional: true
+
+
/@types/fbemitter@2.0.33:
+
resolution: {integrity: sha512-KcSilwdl0D8YgXGL6l9d+rTBm2W7pDyTZrDEw0+IzqQ724676KJtMeO+xHodJewKFWZT+GFWaJubA5mpMxSkcg==}
+
dev: false
+
+
/@types/flux@3.1.12:
+
resolution: {integrity: sha512-HZ8o/DTVNgcgnXoDyn0ZnjqEZMT4Chr4w5ktMQSbQAnqVDklasmRqNGd2agZDsk5i0jYHQLgQQuM782bWG7fUA==}
+
dependencies:
+
'@types/fbemitter': 2.0.33
+
'@types/react': 18.2.22
+
dev: false
+
+
/@types/node@18.17.17:
+
resolution: {integrity: sha512-cOxcXsQ2sxiwkykdJqvyFS+MLQPLvIdwh5l6gNg8qF6s+C7XSkEWOZjK+XhUZd+mYvHV/180g2cnCcIl4l06Pw==}
+
dev: false
+
+
/@types/node@20.6.2:
+
resolution: {integrity: sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==}
+
dev: false
+
+
/@types/prop-types@15.7.6:
+
resolution: {integrity: sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg==}
+
dev: false
+
+
/@types/react@18.2.22:
+
resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==}
+
dependencies:
+
'@types/prop-types': 15.7.6
+
'@types/scheduler': 0.16.3
+
csstype: 3.1.2
+
dev: false
+
+
/@types/scheduler@0.16.3:
+
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
+
dev: false
+
+
/ansi-regex@5.0.1:
+
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+
engines: {node: '>=8'}
+
dev: false
+
+
/ansi-regex@6.0.1:
+
resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
+
engines: {node: '>=12'}
+
dev: false
+
+
/ansi-styles@4.3.0:
+
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+
engines: {node: '>=8'}
+
dependencies:
+
color-convert: 2.0.1
+
dev: false
+
+
/ansi-styles@6.2.1:
+
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
+
engines: {node: '>=12'}
+
dev: false
+
+
/balanced-match@1.0.2:
+
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
dev: false
+
+
/brace-expansion@1.1.11:
+
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+
dependencies:
+
balanced-match: 1.0.2
+
concat-map: 0.0.1
+
dev: false
+
+
/brace-expansion@2.0.1:
+
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+
dependencies:
+
balanced-match: 1.0.2
+
dev: false
+
+
/color-convert@2.0.1:
+
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+
engines: {node: '>=7.0.0'}
+
dependencies:
+
color-name: 1.1.4
+
dev: false
+
+
/color-name@1.1.4:
+
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
dev: false
+
+
/commander@5.1.0:
+
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
+
engines: {node: '>= 6'}
+
dev: false
+
+
/concat-map@0.0.1:
+
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+
dev: false
+
+
/cross-spawn@7.0.3:
+
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
+
engines: {node: '>= 8'}
+
dependencies:
+
path-key: 3.1.1
+
shebang-command: 2.0.0
+
which: 2.0.2
+
dev: false
+
+
/csstype@3.1.2:
+
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
+
dev: false
+
+
/eastasianwidth@0.2.0:
+
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
dev: false
+
+
/emoji-regex@8.0.0:
+
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
dev: false
+
+
/emoji-regex@9.2.2:
+
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
dev: false
+
+
/esbuild-copy-static-files@0.1.0:
+
resolution: {integrity: sha512-KlpmYqANA1t2nZavEdItfcOjJC6wbHA21v35HJWN32DddGTWKNNGDKljUzbCPojmpD+wAw8/DXr5abJ4jFCE0w==}
+
dev: true
+
+
/esbuild@0.19.3:
+
resolution: {integrity: sha512-UlJ1qUUA2jL2nNib1JTSkifQTcYTroFqRjwCFW4QYEKEsixXD5Tik9xML7zh2gTxkYTBKGHNH9y7txMwVyPbjw==}
+
engines: {node: '>=12'}
+
hasBin: true
+
requiresBuild: true
+
optionalDependencies:
+
'@esbuild/android-arm': 0.19.3
+
'@esbuild/android-arm64': 0.19.3
+
'@esbuild/android-x64': 0.19.3
+
'@esbuild/darwin-arm64': 0.19.3
+
'@esbuild/darwin-x64': 0.19.3
+
'@esbuild/freebsd-arm64': 0.19.3
+
'@esbuild/freebsd-x64': 0.19.3
+
'@esbuild/linux-arm': 0.19.3
+
'@esbuild/linux-arm64': 0.19.3
+
'@esbuild/linux-ia32': 0.19.3
+
'@esbuild/linux-loong64': 0.19.3
+
'@esbuild/linux-mips64el': 0.19.3
+
'@esbuild/linux-ppc64': 0.19.3
+
'@esbuild/linux-riscv64': 0.19.3
+
'@esbuild/linux-s390x': 0.19.3
+
'@esbuild/linux-x64': 0.19.3
+
'@esbuild/netbsd-x64': 0.19.3
+
'@esbuild/openbsd-x64': 0.19.3
+
'@esbuild/sunos-x64': 0.19.3
+
'@esbuild/win32-arm64': 0.19.3
+
'@esbuild/win32-ia32': 0.19.3
+
'@esbuild/win32-x64': 0.19.3
+
dev: true
+
+
/foreground-child@3.1.1:
+
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
+
engines: {node: '>=14'}
+
dependencies:
+
cross-spawn: 7.0.3
+
signal-exit: 4.1.0
+
dev: false
+
+
/fs.realpath@1.0.0:
+
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
dev: false
+
+
/glob@10.3.4:
+
resolution: {integrity: sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==}
+
engines: {node: '>=16 || 14 >=14.17'}
+
hasBin: true
+
dependencies:
+
foreground-child: 3.1.1
+
jackspeak: 2.3.3
+
minimatch: 9.0.3
+
minipass: 7.0.3
+
path-scurry: 1.10.1
+
dev: false
+
+
/glob@7.2.3:
+
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+
dependencies:
+
fs.realpath: 1.0.0
+
inflight: 1.0.6
+
inherits: 2.0.4
+
minimatch: 3.1.2
+
once: 1.4.0
+
path-is-absolute: 1.0.1
+
dev: false
+
+
/inflight@1.0.6:
+
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+
dependencies:
+
once: 1.4.0
+
wrappy: 1.0.2
+
dev: false
+
+
/inherits@2.0.4:
+
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
dev: false
+
+
/is-fullwidth-code-point@3.0.0:
+
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+
engines: {node: '>=8'}
+
dev: false
+
+
/isexe@2.0.0:
+
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
dev: false
+
+
/jackspeak@2.3.3:
+
resolution: {integrity: sha512-R2bUw+kVZFS/h1AZqBKrSgDmdmjApzgY0AlCPumopFiAlbUxE2gf+SCuBzQ0cP5hHmUmFYF5yw55T97Th5Kstg==}
+
engines: {node: '>=14'}
+
dependencies:
+
'@isaacs/cliui': 8.0.2
+
optionalDependencies:
+
'@pkgjs/parseargs': 0.11.0
+
dev: false
+
+
/lru-cache@10.0.1:
+
resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==}
+
engines: {node: 14 || >=16.14}
+
dev: false
+
+
/minimatch@3.1.2:
+
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
dependencies:
+
brace-expansion: 1.1.11
+
dev: false
+
+
/minimatch@9.0.3:
+
resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==}
+
engines: {node: '>=16 || 14 >=14.17'}
+
dependencies:
+
brace-expansion: 2.0.1
+
dev: false
+
+
/minipass@7.0.3:
+
resolution: {integrity: sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==}
+
engines: {node: '>=16 || 14 >=14.17'}
+
dev: false
+
+
/once@1.4.0:
+
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
dependencies:
+
wrappy: 1.0.2
+
dev: false
+
+
/path-is-absolute@1.0.1:
+
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+
engines: {node: '>=0.10.0'}
+
dev: false
+
+
/path-key@3.1.1:
+
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+
engines: {node: '>=8'}
+
dev: false
+
+
/path-scurry@1.10.1:
+
resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==}
+
engines: {node: '>=16 || 14 >=14.17'}
+
dependencies:
+
lru-cache: 10.0.1
+
minipass: 7.0.3
+
dev: false
+
+
/shebang-command@2.0.0:
+
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+
engines: {node: '>=8'}
+
dependencies:
+
shebang-regex: 3.0.0
+
dev: false
+
+
/shebang-regex@3.0.0:
+
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+
engines: {node: '>=8'}
+
dev: false
+
+
/signal-exit@4.1.0:
+
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+
engines: {node: '>=14'}
+
dev: false
+
+
/standalone-electron-types@1.0.0:
+
resolution: {integrity: sha512-0HOi/tlTz3mjWhsAz4uRbpQcHMZ+ifj1JzWW9nugykOHClBBG77ps8QinrzX1eow4Iw2pnC+RFaSYRgufF4BOg==}
+
dependencies:
+
'@types/node': 18.17.17
+
dev: false
+
+
/string-width@4.2.3:
+
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+
engines: {node: '>=8'}
+
dependencies:
+
emoji-regex: 8.0.0
+
is-fullwidth-code-point: 3.0.0
+
strip-ansi: 6.0.1
+
dev: false
+
+
/string-width@5.1.2:
+
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+
engines: {node: '>=12'}
+
dependencies:
+
eastasianwidth: 0.2.0
+
emoji-regex: 9.2.2
+
strip-ansi: 7.1.0
+
dev: false
+
+
/strip-ansi@6.0.1:
+
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+
engines: {node: '>=8'}
+
dependencies:
+
ansi-regex: 5.0.1
+
dev: false
+
+
/strip-ansi@7.1.0:
+
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
+
engines: {node: '>=12'}
+
dependencies:
+
ansi-regex: 6.0.1
+
dev: false
+
+
/which@2.0.2:
+
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+
engines: {node: '>= 8'}
+
hasBin: true
+
dependencies:
+
isexe: 2.0.0
+
dev: false
+
+
/wrap-ansi@7.0.0:
+
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+
engines: {node: '>=10'}
+
dependencies:
+
ansi-styles: 4.3.0
+
string-width: 4.2.3
+
strip-ansi: 6.0.1
+
dev: false
+
+
/wrap-ansi@8.1.0:
+
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+
engines: {node: '>=12'}
+
dependencies:
+
ansi-styles: 6.2.1
+
string-width: 5.1.2
+
strip-ansi: 7.1.0
+
dev: false
+
+
/wrappy@1.0.2:
+
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
dev: false
+2
pnpm-workspace.yaml
···
+
packages:
+
- "packages/*"
+18
tsconfig.json
···
+
{
+
"compilerOptions": {
+
"target": "es2016",
+
"module": "es6",
+
"esModuleInterop": true,
+
"forceConsistentCasingInFileNames": true,
+
"strict": true,
+
"skipLibCheck": true,
+
"moduleResolution": "bundler",
+
"baseUrl": "./packages/",
+
"jsx": "react",
+
+
// disable unreachable code detection because it breaks with esbuild labels
+
"allowUnreachableCode": true
+
},
+
"include": ["./packages/**/*", "./env.d.ts"],
+
"exclude": ["node_modules"]
+
}