mirror of
https://github.com/TurboWarp/desktop.git
synced 2024-11-25 16:36:20 +08:00
Add Discord Rich Presence Support (#1000)
This commit is contained in:
parent
b1075e81f4
commit
ce86dbcd1d
BIN
art/rich-presence-icon.png
Normal file
BIN
art/rich-presence-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
@ -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
486
src-main/rich-presence.js
Normal 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();
|
@ -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();
|
||||
|
@ -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'));
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
});
|
||||
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user