// MIT License // Copyright (c) 2020-2022 Dawid Papiewski "SpacingBat3" // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. import type {Server, WebSocket} from "ws"; import {inviteWindow, createInviteWindow} from "./window"; async function wsLog(message: string, ...args: unknown[]) { console.log("[WebSocket] " + message, ...args); } /** Generates an inclusive range (as `Array`) from `start` to `end`. */ function range(start: number, end: number) { return Array.from({length: end - start + 1}, (_v, k) => start + k); } interface InviteResponse { /** Response type/command. */ cmd: "INVITE_BROWSER"; /** Response arguments. */ args: { /** An invitation code. */ code: string; }; /** Nonce indentifying the communication. */ nonce: string; } function isInviteResponse(data: unknown): data is InviteResponse { if (!(data instanceof Object)) return false; if ((data as Partial)?.cmd !== "INVITE_BROWSER") return false; if (typeof (data as Partial)?.args?.code !== "string") return false; if (typeof (data as Partial)?.nonce !== "string") return false; return true; } const messages = { /** * A fake, hard-coded Discord command to spoof the presence of * official Discord client (which makes browser to actually start a * communication with the ArmCord). */ handShake: { /** Message command. */ cmd: "DISPATCH", /** Message data. */ data: { /** Message scheme version. */ v: 1, /** Client properties. */ config: { /** Discord CDN host (hard-coded for `dicscord.com` instance). */ cdn_host: "cdn.discordapp.com", /** API endpoint (hard-coded for `dicscord.com` instance). */ api_endpoint: "//discord.com/api", /** Client type. Can be (probably) `production` or `canary`. */ environment: "production" } }, evt: "READY", nonce: null } }; /** * Tries to reserve the server at given port. * * @returns `Promise`, which always resolves (either to `Server` on * success or `null` on failure). */ async function getServer(port: number) { const {WebSocketServer} = await import("ws"); return new Promise | null>((resolve) => { const wss = new WebSocketServer({host: "127.0.0.1", port}); wss.once("listening", () => resolve(wss)); wss.once("error", () => resolve(null)); }); } /** * Tries to start a WebSocket server at given port range. If it suceed, it will * listen to the browser requests which are meant to be sent to official * Discord client. * * Currently it supports only the invitation link requests. * */ export default async function startServer() { function isJsonSyntaxCorrect(string: string) { try { JSON.parse(string); } catch { return false; } return true; } /** Known Discord instances, including the official ones. */ const knownInstancesList = [ ["Discord", new URL("https://discord.com/app")], ["Discord Canary", new URL("https://canary.discord.com/app")], ["Discord PTB", new URL("https://ptb.discord.com/app")], ["Fosscord", new URL("https://dev.fosscord.com/app")] ] as const; let wss = null, wsPort = 6463; for (const port of range(6463, 6472)) { wss = await getServer(port); if (wss !== null) { void wsLog("ArmCord is listening at " + port.toString()); wsPort = port; break; } } if (wss === null) return; let lock = false; wss.on("connection", (wss, request) => { const origin = request.headers.origin ?? "https://discord.com"; let known = false; for (const instance of knownInstancesList) { if (instance[1].origin === origin) known = true; } if (!known) return; wss.send(JSON.stringify(messages.handShake)); wss.once("message", (data, isBinary) => { if (lock) return; lock = true; let parsedData: unknown = data; if (!isBinary) parsedData = data.toString(); if (isJsonSyntaxCorrect(parsedData as string)) parsedData = JSON.parse(parsedData as string); if (isInviteResponse(parsedData)) { // Replies to browser, so it finds the communication successful. wss.send( JSON.stringify({ cmd: parsedData.cmd, data: { invite: null, code: parsedData.args.code }, evt: null, nonce: parsedData.nonce }) ); createInviteWindow(); const child = inviteWindow; if (child === undefined) return; void child.loadURL(origin + "/invite/" + parsedData.args.code); child.webContents.once("did-finish-load", () => { child.show(); }); child.webContents.once("will-navigate", () => { lock = false; child.close(); }); child.on("close", (e) => { lock = false; }); // Blocks requests to ArmCord's WS, to prevent loops. child.webContents.session.webRequest.onBeforeRequest( { urls: ["ws://127.0.0.1:" + wsPort.toString() + "/*"] }, (_details, callback) => callback({cancel: true}) ); } }); }); }