Add Discord Rich Presence Support (#1000)

This commit is contained in:
Cubester 2024-05-24 21:48:25 -04:00 committed by GitHub
parent b1075e81f4
commit ce86dbcd1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 544 additions and 1 deletions

BIN
art/rich-presence-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -263,6 +263,10 @@
"string": "Exit F11 fullscreen mode (not the fullscreen button in the editor) when pressing escape",
"developer_comment": "Refering to when an entire window is in fullscreen by pressing F11 or the maximize button on macOS, not the fullscreen mode you get by pressing the fullscreen button in the editor."
},
"desktop-settings.rich-presence": {
"string": "Enable Rich Presence",
"developer_comment": "Option to enable Discord rich presence support, which shows the name of the project someone is editing on their Discord profile. For compliance with Scratch policies don't use the word \"Discord\" in the translation."
},
"desktop-settings.open-user-data": {
"string": "Open User Data",
"developer_comment": "Button in desktop settings to open the user data folder"
@ -450,5 +454,9 @@
"unsafe-path.details": {
"string": "The file you selected ({file}) is in a folder used internally by {APP_NAME}. It is not safe to save projects here as they can be deleted when the app updates. You must choose a different folder.",
"developer_comment": "Message of alert that appears when someone tries to save project in a folder used internally by the app. {file} is replaced with the path and {APP_NAME} with the name of the app."
},
"rich-presence.untitled": {
"string": "Untitled Project",
"developer_comment": "Appears in Discord rich presence when the current project doesn't have a name."
}
}

486
src-main/rich-presence.js Normal file
View File

@ -0,0 +1,486 @@
/*!
This is based on https://github.com/discordjs/RPC, which we use under this license:
MIT License
Copyright (c) 2022 devsnek
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.
*/
const net = require('net');
const pathUtil = require('path');
const nodeCrypto = require('crypto');
const {APP_NAME} = require('./brand');
const {translate} = require('./l10n');
const settings = require('./settings');
// Ask GarboMuffin for changes
// https://discord.com/developers/applications
const APPLICATION_ID = '1243008354037665813';
const LARGE_IMAGE_NAME = 'icon';
const OP_HANDSHAKE = 0;
const OP_FRAME = 1;
const OP_CLOSE = 2;
const OP_PING = 3;
const OP_PONG = 4;
// Note that we can't use the randomUUID from web crypto as we need to support Electron 22.
const nonce = () => nodeCrypto.randomUUID();
/**
* @param {number} i
* @returns {string[]}
*/
const getSocketPaths = (i) => {
if (process.platform === 'win32') {
return [
`\\\\?\\pipe\\discord-ipc-${i}`
];
}
// All other platforms are Unix-like
const tempDir = (
process.env.XDG_RUNTIME_DIR ||
process.env.TMPDIR ||
process.env.TMP ||
process.env.TEMP ||
'/tmp'
);
// There are a lot of ways to install Discord on Linux
if (process.platform === 'linux') {
return [
// Native
pathUtil.join(tempDir, `discord-ipc-${i}`),
// Flatpak
pathUtil.join(tempDir, `app/com.discordapp.Discord/discord-ipc-${i}`),
// Snap
pathUtil.join(tempDir, `snap.discord/discord-ipc-${i}`),
];
}
// macOS and, theoretically, other Unixes
return [
pathUtil.join(tempDir, `discord-ipc-${i}`)
];
};
/**
* @param {string} path
* @returns {Promise<net.Socket>}
*/
const tryOpenSocket = (path) => {
return new Promise((resolve, reject) => {
const socket = net.connect(path);
const onConnect = () => {
removeListeners();
resolve(socket);
};
const onError = (error) => {
removeListeners();
reject(error);
};
const onTimeout = () => {
removeListeners();
reject(new Error('Timed out'));
};
const removeListeners = () => {
socket.off('connect', onConnect);
socket.off('error', onError);
socket.off('timeout', onTimeout);
};
socket.on('connect', onConnect);
socket.on('error', onError);
socket.on('timeout', onTimeout);
});
};
/**
* @returns {Promise<net.Socket>}
*/
const findIPCSocket = async () => {
for (let i = 0; i < 10; i++) {
for (const path of getSocketPaths(i)) {
console.log('trying', path);
try {
return await tryOpenSocket(path)
} catch (e) {
// keep trying the next one
console.error(e);
}
}
}
throw new Error('Could not open IPC');
};
class RichPresence {
constructor () {
/**
* @private
* @type {Buffer}
*/
this.buffer = Buffer.alloc(0);
/**
* @private
* @type {net.Socket|null}
*/
this.socket = null;
/**
* @private
* @type {NodeJS.Timeout|null}
*/
this.reconnectTimeout = null;
/**
* @private
* @type {NodeJS.Timeout|null}
*/
this.activityTimeout = null;
/**
* @private
* @type {string}
*/
this.activityTitle = '';
/**
* @private
* @type {number}
*/
this.activityStartTime = Date.now();
/**
* @private
* @type {boolean}
*/
this.checkedAutomaticEnable = false;
/**
* @private
* @type {boolean}
*/
this.enabled = false;
this.handleSocketData = this.handleSocketData.bind(this);
this.handleSocketClose = this.handleSocketClose.bind(this);
this.handleSocketError = this.handleSocketError.bind(this);
}
checkAutomaticEnable () {
if (this.checkedAutomaticEnable) {
return;
}
this.checkedAutomaticEnable = true;
if (settings.richPresence) {
this.enable();
}
}
enable () {
if (this.enabled) {
return;
}
this.checkedAutomaticEnable = true;
this.enabled = true;
this.connect();
}
disable () {
if (!this.enabled) {
return;
}
this.checkedAutomaticEnable = true;
this.enabled = false;
this.disconnect();
}
/**
* @private
*/
async connect () {
try {
this.socket = await findIPCSocket();
} catch (e) {
console.error(e);
this.stopFurtherWrites();
this.reconnect();
return;
}
if (!this.enabled) {
this.stopFurtherWrites();
return;
}
this.buffer = Buffer.alloc(0);
this.socket.on('data', this.handleSocketData);
this.socket.on('close', this.handleSocketClose);
this.socket.on('error', this.handleSocketError);
this.write(OP_HANDSHAKE, {
v: 1,
client_id: APPLICATION_ID
});
}
/**
* @private
*/
disconnect () {
this.stopFurtherWrites();
if (this.reconnectTimeout) {
// Stop pending reconnection
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
/**
* @private
*/
reconnect () {
if (this.reconnectTimeout || !this.enabled) {
return;
}
console.log('Scheduled a reconnection');
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, 15 * 1000);
}
/**
* @private
* @returns {boolean}
*/
canWrite () {
return !!this.socket && this.socket.readyState === 'open';
}
/**
* @private
* @param {number} op See constants
* @param {unknown} data Object to be JSON.stringify()'d
*/
write (op, data) {
if (!this.canWrite()) {
return;
}
console.log('writing', op, data);
const payloadJSON = JSON.stringify(data);
const payloadLength = Buffer.byteLength(payloadJSON);
const packet = Buffer.alloc(8 + payloadLength);
packet.writeInt32LE(op, 0);
packet.writeInt32LE(payloadLength, 4);
packet.write(payloadJSON, 8, payloadLength);
this.socket.write(packet);
}
/**
* @private
* @param {Buffer} data
*/
handleSocketData (data) {
this.buffer = Buffer.concat([this.buffer, data]);
this.parseBuffer();
}
/**
* @private
*/
handleSocketClose () {
this.stopFurtherWrites();
this.reconnect();
}
/**
* @private
* @param {Error} error
*/
handleSocketError (error) {
// Only catching this to log the error and avoid uncaught main thread error.
// Close event will be fired afterwards, so we don't need to do anything else.
console.error(error);
}
/**
* @private
*/
parseBuffer () {
if (this.buffer.byteLength < 8) {
// Wait for header.
return;
}
const op = this.buffer.readUint32LE(0);
const length = this.buffer.readUint32LE(4);
if (this.buffer.byteLength < 8 + length) {
// Wait for full payload.
return;
}
const payload = this.buffer.subarray(8, 8 + length);
try {
const parsedPayload = JSON.parse(payload.toString('utf-8'));
this.handleMessage(op, parsedPayload);
} catch (e) {
console.error('error parsing rich presence', e);
}
// Regardless of success or failure, discard the packet
this.buffer = this.buffer.subarray(8 + length);
// If there's another packet in the buffer, parse it now
this.parseBuffer();
}
/**
* @private
*/
stopFurtherWrites () {
if (this.socket) {
this.socket.off('data', this.handleSocketData);
this.socket.off('close', this.handleSocketClose);
this.socket.off('error', this.handleSocketError);
this.socket.end();
this.socket = null;
}
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
}
/**
* @private
* @param {number} op See constants
* @param {unknown} data Parsed JSON object
*/
handleMessage (op, data) {
console.log('received', op, data);
switch (op) {
case OP_PING: {
this.write(OP_PONG, data);
break;
}
case OP_CLOSE: {
this.stopFurtherWrites();
// reconnection will be attempted when the socket actually closes
break;
}
case OP_FRAME: {
if (data.evt === 'READY') {
this.handleReady();
}
break;
}
}
}
/**
* @private
*/
handleReady () {
this.writeActivity();
}
/**
* @param {string} title
* @param {number} startTime
*/
setActivity (title, startTime) {
if (title === this.activityTitle && startTime === this.activityStartTime) {
return;
}
this.activityTitle = title;
this.activityStartTime = startTime;
// The first time we receive a valid title is when automatic connection is possible.
if (this.activityTitle) {
this.checkAutomaticEnable();
}
if (this.canWrite() && !this.activityTimeout) {
this.writeActivity();
this.scheduleNextWriteActivity();
}
}
/**
* @private
*/
scheduleNextWriteActivity () {
const oldTitle = this.activityTitle;
const oldStartTime = this.activityStartTime;
// The update activity function in Discord's Game SDK rate limits to 5 updates
// per 20 seconds. We roughly follow that.
// https://github.com/discord/discord-api-docs/blob/main/docs/game_sdk/Activities.md#updateactivity
this.activityTimeout = setTimeout(() => {
if (this.activityTitle !== oldTitle || this.activityStartTime !== oldStartTime) {
this.writeActivity();
this.scheduleNextWriteActivity();
} else {
this.activityTimeout = null;
}
}, 4000);
}
/**
* @private
*/
writeActivity () {
const title = this.activityTitle || translate('rich-presence.untitled');
this.write(OP_FRAME, {
cmd: 'SET_ACTIVITY',
args: {
pid: process.pid,
activity: {
// Needs to be at least 2 characters long and not more than 128, otherwise it is rejected
details: title.padEnd(2, ' ').substring(0, 128),
timestamps: {
start: this.activityStartTime,
},
assets: {
large_image: LARGE_IMAGE_NAME,
large_text: APP_NAME
},
instance: false
}
},
nonce: nonce()
});
}
}
module.exports = new RichPresence();

View File

@ -170,6 +170,13 @@ class Settings {
set exitFullscreenOnEscape(exitFullscreenOnEscape) {
this.data.exitFullscreenOnEscape = exitFullscreenOnEscape;
}
get richPresence () {
return this.data.richPresence === true;
}
set richPresence (richPresence) {
this.data.richPresence = richPresence;
}
}
const settings = new Settings();

View File

@ -4,6 +4,7 @@ const {translate, getStrings, getLocale} = require('../l10n');
const {APP_NAME} = require('../brand');
const settings = require('../settings');
const {isEnabledAtBuildTime} = require('../update-checker');
const RichPresence = require('../rich-presence');
class DesktopSettingsWindow extends AbstractWindow {
constructor () {
@ -32,7 +33,8 @@ class DesktopSettingsWindow extends AbstractWindow {
backgroundThrottling: settings.backgroundThrottling,
bypassCORS: settings.bypassCORS,
spellchecker: settings.spellchecker,
exitFullscreenOnEscape: settings.exitFullscreenOnEscape
exitFullscreenOnEscape: settings.exitFullscreenOnEscape,
richPresence: settings.richPresence
};
});
@ -89,6 +91,16 @@ class DesktopSettingsWindow extends AbstractWindow {
await settings.save();
});
ipc.handle('set-rich-presence', async (event, richPresence) => {
settings.richPresence = richPresence;
if (richPresence) {
RichPresence.enable();
} else {
RichPresence.disable();
}
await settings.save();
});
ipc.handle('open-user-data', async () => {
shell.showItemInFolder(app.getPath('userData'));
});

View File

@ -14,6 +14,7 @@ const {APP_NAME} = require('../brand');
const prompts = require('../prompts');
const settings = require('../settings');
const privilegedFetch = require('../fetch');
const RichPresence = require('../rich-presence.js');
const TYPE_FILE = 'file';
const TYPE_URL = 'url';
@ -203,6 +204,8 @@ class EditorWindow extends ProjectRunningWindow {
this.activeFileIndex = 0;
}
this.openedProjectAt = Date.now();
const getFileByIndex = (index) => {
if (typeof index !== 'number') {
throw new Error('File ID not number');
@ -237,12 +240,20 @@ class EditorWindow extends ProjectRunningWindow {
event.preventDefault();
if (explicitSet && title) {
this.window.setTitle(`${title} - ${APP_NAME}`);
this.projectTitle = title;
} else {
this.window.setTitle(APP_NAME);
this.projectTitle = '';
}
this.updateRichPresence();
});
this.window.setTitle(APP_NAME);
this.window.on('focus', () => {
this.updateRichPresence();
});
const ipc = this.window.webContents.ipc;
ipc.on('is-initially-fullscreen', (e) => {
@ -294,6 +305,7 @@ class EditorWindow extends ProjectRunningWindow {
throw new Error('Not a file');
}
this.activeFileIndex = index;
this.openedProjectAt = Date.now();
this.window.setRepresentedFilename(file.path);
});
@ -558,6 +570,10 @@ class EditorWindow extends ProjectRunningWindow {
return !this.isInEditorFullScreen;
}
updateRichPresence () {
RichPresence.setActivity(this.projectTitle, this.openedProjectAt);
}
/**
* @param {string[]} files
* @param {boolean} fullscreen

View File

@ -12,5 +12,6 @@ contextBridge.exposeInMainWorld('DesktopSettingsPreload', {
setBypassCORS: (bypassCORS) => ipcRenderer.invoke('set-bypass-cors', bypassCORS),
setSpellchecker: (spellchecker) => ipcRenderer.invoke('set-spellchecker', spellchecker),
setExitFullscreenOnEscape: (exitFullscreenOnEscape) => ipcRenderer.invoke('set-exit-fullscreen-on-escape', exitFullscreenOnEscape),
setRichPresence: (richPresence) => ipcRenderer.invoke('set-rich-presence', richPresence),
openUserData: () => ipcRenderer.invoke('open-user-data')
});

View File

@ -296,6 +296,19 @@
document.querySelector('.exit-fullscreen-on-escape-label').textContent = strings['desktop-settings.exit-fullscreen-on-escape'];
</script>
<label>
<input type="checkbox" class="rich-presence-checkbox" autocomplete="off">
<span class="rich-presence-label"></span>
</label>
<script>
const richPresence = document.querySelector('.rich-presence-checkbox');
richPresence.onchange = () => {
DesktopSettingsPreload.setRichPresence(richPresence.checked);
};
richPresence.checked = settings.richPresence;
document.querySelector('.rich-presence-label').textContent = strings['desktop-settings.rich-presence'];
</script>
<button class="open-user-data"></button>
<script>
const openUserData = document.querySelector('.open-user-data');