From 3d0fd7071b27aa5257470eeef68e34f1955a7864 Mon Sep 17 00:00:00 2001 From: smartfrigde <37928912+smartfrigde@users.noreply.github.com> Date: Tue, 19 Apr 2022 15:56:04 +0200 Subject: [PATCH] Add websocket for invitation link requests --- package-lock.json | 59 +++++++++++---- package.json | 5 +- src/socket.ts | 179 ++++++++++++++++++++++++++++++++++++++++++++++ src/window.ts | 19 ++++- 4 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 src/socket.ts diff --git a/package-lock.json b/package-lock.json index 3e32787..1ffb1de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,13 @@ "license": "OSL-3.0", "dependencies": { "electron-context-menu": "^3.1.2", - "electron-tabs": "^0.17.0", - "v8-compile-cache": "^2.3.0" + "v8-compile-cache": "^2.3.0", + "ws": "^8.5.0" }, "devDependencies": { "@types/electron-json-storage": "^4.5.0", "@types/node": "^17.0.24", + "@types/ws": "^8.5.3", "copyfiles": "^2.4.1", "electron": "^18.0.4", "electron-builder": "^22.5.1", @@ -121,6 +122,15 @@ "integrity": "sha512-aveCYRQbgTH9Pssp1voEP7HiuWlD2jW2BO56w+bVrJn04i61yh6mRfoKO6hEYQD9vF+W8Chkwc6j1M36uPkx4g==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -1119,11 +1129,6 @@ "node": ">= 10.0.0" } }, - "node_modules/electron-tabs": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/electron-tabs/-/electron-tabs-0.17.0.tgz", - "integrity": "sha512-jFv6WOeumSR5q2Cf6WOghE7CTdxPB0mSuPw8dGwz1OAG8MJMQn/kd/ghmvRPwoOYK77v4d9YligqjXIQc2oPcg==" - }, "node_modules/electron/node_modules/@types/node": { "version": "16.11.26", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz", @@ -2907,6 +2912,26 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "node_modules/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", @@ -3057,6 +3082,15 @@ "integrity": "sha512-aveCYRQbgTH9Pssp1voEP7HiuWlD2jW2BO56w+bVrJn04i61yh6mRfoKO6hEYQD9vF+W8Chkwc6j1M36uPkx4g==", "dev": true }, + "@types/ws": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -3885,11 +3919,6 @@ } } }, - "electron-tabs": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/electron-tabs/-/electron-tabs-0.17.0.tgz", - "integrity": "sha512-jFv6WOeumSR5q2Cf6WOghE7CTdxPB0mSuPw8dGwz1OAG8MJMQn/kd/ghmvRPwoOYK77v4d9YligqjXIQc2oPcg==" - }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5285,6 +5314,12 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "requires": {} + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index 1ca810a..c121ac4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@types/electron-json-storage": "^4.5.0", "@types/node": "^17.0.24", + "@types/ws": "^8.5.3", "copyfiles": "^2.4.1", "electron": "^18.0.4", "electron-builder": "^22.5.1", @@ -33,8 +34,8 @@ }, "dependencies": { "electron-context-menu": "^3.1.2", - "electron-tabs": "^0.17.0", - "v8-compile-cache": "^2.3.0" + "v8-compile-cache": "^2.3.0", + "ws": "^8.5.0" }, "build": { "appId": "com.smartfridge.armcord", diff --git a/src/socket.ts b/src/socket.ts new file mode 100644 index 0000000..2e73c7c --- /dev/null +++ b/src/socket.ts @@ -0,0 +1,179 @@ +// 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(); + }) + // 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})); + } + }) + }) +} diff --git a/src/window.ts b/src/window.ts index bc00139..cd1c59d 100644 --- a/src/window.ts +++ b/src/window.ts @@ -6,9 +6,10 @@ import {BrowserWindow, shell, app, ipcMain, dialog} from "electron"; import path from "path"; import {checkIfConfigIsBroken, firstRun, getConfig, contentPath} from "./utils"; import {registerIpc} from "./ipc"; +import startServer from "./socket" import contextMenu from "electron-context-menu"; export let mainWindow: BrowserWindow; - +export let inviteWindow: BrowserWindow; let guestWindows: BrowserWindow[] = []; contextMenu({ showSaveImageAs: true, @@ -39,6 +40,7 @@ function doAfterDefiningTheWindow() { } }); console.log(contentPath); + startServer() try { mainWindow.loadFile(contentPath); } catch (e) { @@ -165,3 +167,18 @@ export function createTabsGuest(number: number) { guestWindows[number].loadURL("https://discord.com/app"); } } +export function createInviteWindow() { + inviteWindow = new BrowserWindow({ + width: 800, + height: 600, + title: "ArmCord Invite Manager", + darkTheme: true, + icon: path.join(__dirname, "/assets/icon_transparent.png"), + frame: true, + autoHideMenuBar: true, + webPreferences: { + spellcheck: true + } + }); + inviteWindow.hide() +} \ No newline at end of file