feat: initial commit

This commit is contained in:
2026-06-15 13:39:41 +07:00
commit 0afbecbe83
32 changed files with 6620 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
*.tsbuildinfo
.env
.env.*
.vscode
.idea
+1
View File
@@ -0,0 +1 @@
shamefully-hoist=true
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+34
View File
@@ -0,0 +1,34 @@
# discord-rpc-relay
A minimal Electron application with TypeScript
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ pnpm dev
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
+43
View File
@@ -0,0 +1,43 @@
appId: cafe.kirameki.discord-rpc-relay
productName: Discord RPC Relay
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: discord-rpc-relay
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
plugins: [tailwindcss(), react()]
}
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintConfigPrettier
)
+56
View File
@@ -0,0 +1,56 @@
{
"name": "discord-rpc-relay",
"version": "1.0.0",
"description": "Mirrors Discord Rich Presence (RPC) to multiple running Discord instances",
"main": "./out/main/index.js",
"author": "",
"private": true,
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"koffi": "^3.0.2",
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^22.19.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"prettier": "^3.7.4",
"tailwindcss": "^4.3.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild",
"koffi"
]
}
}
+4883
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+179
View File
@@ -0,0 +1,179 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'
import { join } from 'path'
import * as fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { relay, RelayStatus } from './relay'
function settingsPath(): string {
return join(app.getPath('userData'), 'settings.json')
}
function loadDisabledMirrors(): number[] {
try {
const raw = fs.readFileSync(settingsPath(), 'utf8')
const data = JSON.parse(raw)
return Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : []
} catch {
return []
}
}
function saveDisabledMirrors(indices: number[]): void {
fs.writeFileSync(settingsPath(), JSON.stringify({ disabledMirrors: indices }), 'utf8')
}
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
let quitting = false
function createWindow(): void {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
return
}
mainWindow = new BrowserWindow({
width: 420,
height: 760,
show: false,
autoHideMenuBar: true,
resizable: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.on('close', (e) => {
if (!quitting) {
e.preventDefault()
mainWindow?.destroy()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
function createTray(): void {
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
tray = new Tray(trayIcon)
tray.setToolTip('Discord RPC Relay')
updateTrayMenu(relay.getStatus())
tray.on('click', () => {
createWindow()
})
}
function updateTrayMenu(status: RelayStatus): void {
if (!tray) return
const menu = Menu.buildFromTemplate([
{
label: status.running ? 'Relay: Running' : 'Relay: Stopped',
enabled: false
},
{ type: 'separator' },
{
label: status.running ? 'Stop Relay' : 'Start Relay',
click: async () => {
try {
if (status.running) {
await relay.stop()
} else {
await relay.start()
}
} catch (err) {
console.error(err)
}
}
},
{
label: 'Show Window',
click: () => createWindow()
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
quitting = true
app.quit()
}
}
])
tray.setContextMenu(menu)
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
relay.setDisabledMirrors(loadDisabledMirrors())
ipcMain.handle('relay:get-status', () => relay.getStatus())
ipcMain.handle('relay:start', () => relay.start())
ipcMain.handle('relay:stop', () => relay.stop())
ipcMain.handle('relay:get-autostart', () => app.getLoginItemSettings().openAtLogin)
ipcMain.handle('relay:set-autostart', (_e, enabled: boolean) => {
app.setLoginItemSettings({ openAtLogin: enabled })
return app.getLoginItemSettings().openAtLogin
})
ipcMain.handle('relay:set-mirror-enabled', (_e, index: number, enabled: boolean) => {
const status = relay.setMirrorEnabled(index, enabled)
saveDisabledMirrors(relay.getDisabledMirrors())
return status
})
relay.on('status', (status: RelayStatus) => {
updateTrayMenu(status)
mainWindow?.webContents.send('relay:status', status)
})
createTray()
createWindow()
relay.start().catch((err) => console.error('Failed to start relay:', err))
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
// Keep running in the tray
})
let stopping = false
app.on('before-quit', (e) => {
quitting = true
if (stopping) return
stopping = true
e.preventDefault()
relay.stop().finally(() => app.exit(0))
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Discord IPC frames
* https://discord.com/developers/docs/topics/rpc#payloads
*/
export const OP_HANDSHAKE = 0
export const OP_FRAME = 1
export interface Frame {
op: number
payload: Buffer
}
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
export class FrameReader {
private buf = Buffer.alloc(0)
push(chunk: Buffer): Frame[] {
this.buf = Buffer.concat([this.buf, chunk])
const frames: Frame[] = []
for (;;) {
if (this.buf.length < 8) break
const op = this.buf.readUInt32LE(0)
const len = this.buf.readUInt32LE(4)
if (this.buf.length < 8 + len) break
frames.push({ op, payload: Buffer.from(this.buf.subarray(8, 8 + len)) })
this.buf = this.buf.subarray(8 + len)
}
return frames
}
}
export function encodeFrame(op: number, payload: Buffer): Buffer {
const header = Buffer.alloc(8)
header.writeUInt32LE(op, 0)
header.writeUInt32LE(payload.length, 4)
return Buffer.concat([header, payload])
}
/** Parses a non-handshake frame's JSON payload, returning null if it isn't JSON. */
export function parseFramePayload(frame: Frame): Record<string, unknown> | null {
try {
return JSON.parse(frame.payload.toString('utf8'))
} catch {
return null
}
}
+35
View File
@@ -0,0 +1,35 @@
import * as net from 'net'
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
/**
* A lazily-established connection to a secondary Discord instance that
* mirrors the primary connection's handshake and SET_ACTIVITY frames.
*/
export class MirrorConnection {
private readonly sock: net.Socket
private readonly reader = new FrameReader()
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
this.sock = net.createConnection(socketPath)
this.sock.on('connect', () => {
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
})
// Drain responses; the mirror connection only needs to look like a real client.
this.sock.on('data', (chunk) => this.reader.push(chunk))
this.sock.on('error', onClose)
this.sock.on('close', onClose)
}
sendActivity(payload: Buffer): void {
if (this.sock.writable) {
this.sock.write(encodeFrame(OP_FRAME, payload))
}
}
destroy(): void {
this.sock.destroy()
}
}
+8
View File
@@ -0,0 +1,8 @@
import { PosixPlatform } from './posix'
import { UnsupportedPlatform } from './unsupported'
import type { RelayPlatform } from './types'
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
export const platform: RelayPlatform =
process.platform === 'win32' ? new UnsupportedPlatform() : new PosixPlatform()
+255
View File
@@ -0,0 +1,255 @@
import * as fs from 'fs'
import * as net from 'net'
import * as path from 'path'
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
const REAL_PREFIX = 'discord-ipc-real-'
const FAKE_INDEX = 0
const MAX_SOCKETS = 10
function runtimeDir(): string {
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
if (process.platform === 'darwin') return process.env.TMPDIR ?? '/tmp'
return `/run/user/${process.getuid?.() ?? 0}`
}
function ipcPath(index: number): string {
return path.join(runtimeDir(), `discord-ipc-${index}`)
}
function claimedPath(index: number): string {
return path.join(runtimeDir(), `${REAL_PREFIX}${index}`)
}
/**
* Linux + macOS implementation. Discord listens on Unix domain sockets named
* discord-ipc-0..9 in the runtime directory. We take over index 0 by renaming
* the real socket out of the way and binding our own server in its place,
* connecting through to the renamed socket for passthrough.
*/
export class PosixPlatform implements RelayPlatform {
readonly isSupported = true
fakeSocketPath(): string {
return ipcPath(FAKE_INDEX)
}
removeFakeSocket(fakePath: string): void {
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
}
finalizeFakeSocket(fakePath: string): void {
fs.chmodSync(fakePath, 0o777)
}
fakeSocketExists(fakePath: string): boolean {
return fs.existsSync(fakePath)
}
async recoverLeftoverSockets(): Promise<void> {
for (let i = 0; i < MAX_SOCKETS; i++) {
const leftover = claimedPath(i)
const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
if (!fs.existsSync(original)) {
fs.renameSync(leftover, original)
continue
}
// The original path may be a dead fake socket left behind by a
// relay process that died without cleaning up. If nothing is
// listening there, remove it and restore the real socket.
if (!(await isSocketAlive(original))) {
fs.unlinkSync(original)
fs.renameSync(leftover, original)
}
}
}
discoverAndClaim(): ClaimedSocket[] {
const found: ClaimedSocket[] = []
for (let i = 0; i < MAX_SOCKETS; i++) {
const claimed = this.discoverNewSocket(i)
if (claimed) found.push(claimed)
}
return found
}
discoverNewSocket(index: number): ClaimedSocket | null {
const src = ipcPath(index)
if (!fs.existsSync(src)) return null
const dst = claimedPath(index)
fs.renameSync(src, dst)
return { index, path: dst }
}
restoreSocket(claimed: ClaimedSocket): void {
const original = ipcPath(claimed.index)
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
fs.renameSync(claimed.path, original)
}
}
getInstanceProcess(index: number): ProcessInfo | null {
// The kernel reports a Unix socket's *original* bind path in
// /proc/net/unix even after the file is renamed on disk, so look up
// owners by the pre-rename discord-ipc-N path rather than the claimed one.
return findSocketOwner(ipcPath(index))
}
getPeerProcess(fd: number): ProcessInfo | null {
return findPeerProcess(fd)
}
}
function isSocketAlive(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
const sock = net.createConnection(socketPath)
sock.once('connect', () => {
sock.destroy()
resolve(true)
})
sock.once('error', () => resolve(false))
})
}
const SOCKET_OWNER_CACHE_MS = 2000
const socketOwnerCache = new Map<string, { value: ProcessInfo | null; at: number }>()
/**
* Identifies the process listening on a Unix socket file by cross-referencing
* /proc/net/unix (path -> inode) with /proc/*\/fd (inode -> pid). Linux only;
* results are cached briefly since this scans every process's open fds.
*/
function findSocketOwner(socketPath: string): ProcessInfo | null {
if (process.platform !== 'linux') return null
const cached = socketOwnerCache.get(socketPath)
if (cached && Date.now() - cached.at < SOCKET_OWNER_CACHE_MS) {
return cached.value
}
const result = locateSocketOwner(socketPath)
socketOwnerCache.set(socketPath, { value: result, at: Date.now() })
return result
}
function locateSocketOwner(socketPath: string): ProcessInfo | null {
// Multiple sockets can be bound to the same path (our own fake server
// shares discord-ipc-0's path with the real Discord socket after rename),
// so check every matching inode and skip the one owned by this process.
for (const inode of findInodesForPath(socketPath)) {
const owner = findProcessForInode(inode)
if (owner && owner.pid !== process.pid) return owner
}
return null
}
function findInodesForPath(socketPath: string): string[] {
const inodes: string[] = []
try {
const unixTable = fs.readFileSync('/proc/net/unix', 'utf8')
for (const line of unixTable.split('\n')) {
if (line.endsWith(` ${socketPath}`)) {
inodes.push(line.trim().split(/\s+/)[6])
}
}
} catch {
return inodes
}
return inodes
}
function findProcessForInode(inode: string): ProcessInfo | null {
return findProcessForInodes(new Set([inode]))
}
function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
try {
for (const pidStr of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(pidStr)) continue
if (parseInt(pidStr, 10) === process.pid) continue
const fdDir = `/proc/${pidStr}/fd`
let fds: string[]
try {
fds = fs.readdirSync(fdDir)
} catch {
continue
}
for (const fd of fds) {
let link: string
try {
link = fs.readlinkSync(path.join(fdDir, fd))
} catch {
continue
}
const match = /^socket:\[(\d+)\]$/.exec(link)
if (match && inodes.has(match[1])) {
return { pid: parseInt(pidStr, 10), name: processName(pidStr) }
}
}
}
} catch {
return null
}
return null
}
function processName(pidStr: string): string {
try {
return fs.readFileSync(`/proc/${pidStr}/comm`, 'utf8').trim()
} catch {
return pidStr
}
}
interface GetSockOpt {
(sockfd: number, level: number, optname: number, optval: Buffer, optlen: Buffer): number
}
let getsockopt: GetSockOpt | null | undefined
function loadGetSockOpt(): GetSockOpt | null {
if (getsockopt !== undefined) return getsockopt
if (process.platform !== 'linux') {
getsockopt = null
return getsockopt
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const koffi = require('koffi')
const libc = koffi.load('libc.so.6')
getsockopt = libc.func(
'int getsockopt(int sockfd, int level, int optname, void *optval, int *optlen)'
) as GetSockOpt
} catch {
getsockopt = null
}
return getsockopt
}
const SOL_SOCKET = 1
const SO_PEERCRED = 17
const UCRED_SIZE = 12 // struct ucred { pid_t pid; uid_t uid; gid_t gid; }
function findPeerProcess(localFd: number): ProcessInfo | null {
const fn = loadGetSockOpt()
if (!fn) return null
const optval = Buffer.alloc(UCRED_SIZE)
const optlen = Buffer.alloc(4)
optlen.writeInt32LE(UCRED_SIZE, 0)
if (fn(localFd, SOL_SOCKET, SO_PEERCRED, optval, optlen) !== 0) return null
const pid = optval.readInt32LE(0)
if (pid <= 0) return null
return { pid, name: processName(String(pid)) }
}
+45
View File
@@ -0,0 +1,45 @@
export interface ProcessInfo {
pid: number
name: string
}
export interface ClaimedSocket {
index: number
path: string
}
/**
* Platform-specific primitives the relay needs to take over Discord's IPC
* sockets and identify the processes on either end of a connection.
*
* Implement this interface to add support for a new OS; `RpcRelay` itself
* contains no platform-specific code.
*/
export interface RelayPlatform {
readonly isSupported: boolean
fakeSocketPath(): string
removeFakeSocket(fakePath: string): void
finalizeFakeSocket(fakePath: string): void
/** Checks whether the fake socket file still exists on disk (a restarted Discord client can unlink and rebind it out from under us). */
fakeSocketExists(fakePath: string): boolean
/**
* Restores sockets left claimed from a previous run that crashed before
* it could call restoreSocket(), so a fresh start() can discover them.
*/
recoverLeftoverSockets(): Promise<void>
discoverAndClaim(): ClaimedSocket[]
discoverNewSocket(index: number): ClaimedSocket | null
restoreSocket(claimed: ClaimedSocket): void
getInstanceProcess(index: number): ProcessInfo | null
getPeerProcess(fd: number): ProcessInfo | null
}
+50
View File
@@ -0,0 +1,50 @@
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
const NOT_SUPPORTED = new Error('Platform not supported')
/**
* Placeholder for platforms without a real implementation yet.
*/
export class UnsupportedPlatform implements RelayPlatform {
readonly isSupported = false
fakeSocketPath(): string {
throw NOT_SUPPORTED
}
removeFakeSocket(): void {
throw NOT_SUPPORTED
}
finalizeFakeSocket(): void {
throw NOT_SUPPORTED
}
fakeSocketExists(): boolean {
throw NOT_SUPPORTED
}
async recoverLeftoverSockets(): Promise<void> {
throw NOT_SUPPORTED
}
discoverAndClaim(): ClaimedSocket[] {
throw NOT_SUPPORTED
}
discoverNewSocket(): ClaimedSocket | null {
throw NOT_SUPPORTED
}
restoreSocket(): void {
throw NOT_SUPPORTED
}
getInstanceProcess(): ProcessInfo | null {
return null
}
getPeerProcess(): ProcessInfo | null {
return null
}
}
+435
View File
@@ -0,0 +1,435 @@
import { EventEmitter } from 'events'
import * as net from 'net'
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
import { MirrorConnection } from './mirror-connection'
import { platform, type ClaimedSocket, type ProcessInfo } from './platform'
const MAX_SOCKETS = 10
const DISCOVERY_INTERVAL_MS = 3000
interface AppAsset {
id: string
type: number
name: string
}
const appAssetCache = new Map<string, Map<string, string>>()
async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
const cached = appAssetCache.get(appId)
if (cached) return cached
const map = new Map<string, string>()
try {
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
if (res.ok) {
const assets = (await res.json()) as AppAsset[]
for (const asset of assets) map.set(asset.name, asset.id)
}
} catch {
// Network error: cache an empty map so we don't retry every frame.
}
appAssetCache.set(appId, map)
return map
}
async function resolveAssetImage(
key: string | undefined,
appId: string | null
): Promise<string | null> {
if (!key) return null
if (key.startsWith('mp:external/')) {
return `https://media.discordapp.net/external/${key.slice('mp:external/'.length)}`
}
if (key.startsWith('mp:')) {
return `https://media.discordapp.net/${key.slice('mp:'.length)}`
}
if (key.startsWith('http://') || key.startsWith('https://')) return key
if (!appId) return null
// Numeric snowflake asset ids can be used directly.
if (/^\d+$/.test(key)) return `https://cdn.discordapp.com/app-assets/${appId}/${key}.png`
// Named assets need resolving to their numeric id via the app's asset list.
const assetMap = await getAppAssetMap(appId)
const assetId = assetMap.get(key)
if (!assetId) return null
return `https://cdn.discordapp.com/app-assets/${appId}/${assetId}.png`
}
export interface RelayInstance {
index: number
path: string
isPrimary: boolean
enabled: boolean
process: ProcessInfo | null
}
export interface ConnectedClient {
id: number
process: ProcessInfo | null
}
export interface ActivityAssets {
largeImage: string | null
largeText: string | null
smallImage: string | null
smallText: string | null
}
export interface ActivityTimestamps {
start: number | null
end: number | null
}
export interface ActivityButton {
label: string
url: string
}
export interface LastActivity {
app: string | null
details: string | null
state: string | null
assets: ActivityAssets | null
timestamps: ActivityTimestamps | null
buttons: ActivityButton[]
at: number
}
export interface RelayStatus {
running: boolean
unsupported: boolean
instances: RelayInstance[]
connectedClients: ConnectedClient[]
lastActivity: LastActivity | null
error: string | null
}
/**
* Takes over Discord's primary IPC socket (discord-ipc-0), passing every
* frame through to the real primary instance unchanged, while mirroring the
* handshake and SET_ACTIVITY frames to any other running Discord instances.
*/
export class RpcRelay extends EventEmitter {
private server: net.Server | null = null
private claimed: ClaimedSocket[] = []
private disabledMirrors = new Set<number>()
private connectedClients = new Map<number, ConnectedClient>()
private nextClientId = 1
private discoveryTimer: NodeJS.Timeout | null = null
private lastActivity: LastActivity | null = null
private running = false
private lastError: string | null = null
private restarting = false
getStatus(): RelayStatus {
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
index,
path: socketPath,
isPrimary: i === 0,
enabled: i === 0 || !this.disabledMirrors.has(index),
process: platform.getInstanceProcess(index)
}))
return {
running: this.running,
unsupported: !platform.isSupported,
instances,
connectedClients: [...this.connectedClients.values()],
lastActivity: this.lastActivity,
error: this.lastError
}
}
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
if (index === this.primaryIndex()) return this.getStatus()
if (enabled) {
this.disabledMirrors.delete(index)
} else {
this.disabledMirrors.add(index)
}
this.emitStatus()
return this.getStatus()
}
getDisabledMirrors(): number[] {
return [...this.disabledMirrors]
}
setDisabledMirrors(indices: number[]): void {
this.disabledMirrors = new Set(indices)
}
async start(): Promise<void> {
if (this.running) return
this.lastError = null
if (!platform.isSupported) {
this.lastError = 'This platform is not supported'
this.emitStatus()
throw new Error(this.lastError)
}
await platform.recoverLeftoverSockets()
this.claimed = platform.discoverAndClaim()
if (this.claimed.length === 0) {
this.lastError = 'No running Discord clients found (no discord-ipc-N sockets)'
this.emitStatus()
throw new Error(this.lastError)
}
const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
this.server = net.createServer((sock) => this.handleClient(sock))
this.server.on('error', (err) => {
this.lastError = err.message
this.emitStatus()
})
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(fake, () => {
this.server!.removeListener('error', reject)
resolve()
})
})
platform.finalizeFakeSocket(fake)
this.running = true
this.startDiscoveryTimer()
this.emitStatus()
}
async stop(): Promise<void> {
if (!this.running) return
this.running = false
this.stopDiscoveryTimer()
if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve()))
this.server = null
}
platform.removeFakeSocket(platform.fakeSocketPath())
for (const claimed of this.claimed) platform.restoreSocket(claimed)
this.claimed = []
this.connectedClients.clear()
this.emitStatus()
}
private primaryIndex(): number | undefined {
return this.claimed[0]?.index
}
private startDiscoveryTimer(): void {
this.stopDiscoveryTimer()
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS)
}
private stopDiscoveryTimer(): void {
if (this.discoveryTimer) {
clearInterval(this.discoveryTimer)
this.discoveryTimer = null
}
}
/** Picks up Discord instances launched after the relay started, and recovers from a stolen primary socket. */
private discoverNewInstances(): void {
if (!this.running) return
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
const claimedIndices = new Set(this.claimed.map((c) => c.index))
let changed = false
for (let i = 1; i < MAX_SOCKETS; i++) {
if (claimedIndices.has(i)) continue
const found = platform.discoverNewSocket(i)
if (found) {
this.claimed.push(found)
changed = true
}
}
if (changed) this.emitStatus()
}
private handleClient(client: net.Socket): void {
const clientId = this.nextClientId++
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
this.connectedClients.set(clientId, {
id: clientId,
process: fd !== undefined ? platform.getPeerProcess(fd) : null
})
this.emitStatus()
const primaryPath = this.claimed[0].path
const primary = net.createConnection(primaryPath)
const clientReader = new FrameReader()
const primaryReader = new FrameReader()
let handshakePayload: Buffer | null = null
const mirrors = new Map<number, MirrorConnection>()
const cleanup = (): void => {
client.destroy()
primary.destroy()
for (const mirror of mirrors.values()) mirror.destroy()
mirrors.clear()
this.connectedClients.delete(clientId)
this.emitStatus()
}
let clientAppId: string | null = null
let primaryConnected = false
const pendingToPrimary: Buffer[] = []
client.on('data', (chunk) => {
for (const frame of clientReader.push(chunk)) {
if (frame.op === OP_HANDSHAKE) {
handshakePayload = frame.payload
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null
}
const encoded = encodeFrame(frame.op, frame.payload)
if (primaryConnected && primary.writable) {
primary.write(encoded)
} else {
pendingToPrimary.push(encoded)
}
if (handshakePayload) this.mirrorFrame(frame, handshakePayload, mirrors)
void this.recordActivity(frame, clientAppId)
}
})
primary.on('connect', () => {
primaryConnected = true
for (const encoded of pendingToPrimary.splice(0)) primary.write(encoded)
primary.on('data', (chunk) => {
for (const frame of primaryReader.push(chunk)) {
if (client.writable) client.write(encodeFrame(frame.op, frame.payload))
}
})
})
client.on('error', cleanup)
primary.on('error', (err) => {
cleanup()
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
void this.restart()
} else {
this.lastError = `Primary connection error: ${err.message}`
this.emitStatus()
}
})
client.on('close', cleanup)
primary.on('close', cleanup)
}
/** Restarts the relay, picking up any Discord instance that has replaced its IPC socket. */
private async restart(): Promise<void> {
if (this.restarting) return
this.restarting = true
try {
await this.stop()
await this.start()
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
this.emitStatus()
} finally {
this.restarting = false
}
}
/** Forwards the handshake and SET_ACTIVITY frames to every enabled mirror instance. */
private mirrorFrame(
frame: Frame,
handshakePayload: Buffer,
mirrors: Map<number, MirrorConnection>
): void {
const isSetActivity =
frame.op !== OP_HANDSHAKE && parseFramePayload(frame)?.cmd === 'SET_ACTIVITY'
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
for (let i = 1; i < this.claimed.length; i++) {
const { index, path: mirrorPath } = this.claimed[i]
if (this.disabledMirrors.has(index)) {
mirrors.get(i)?.destroy()
mirrors.delete(i)
continue
}
let mirror = mirrors.get(i)
if (!mirror) {
mirror = new MirrorConnection(mirrorPath, handshakePayload, () => mirrors.delete(i))
mirrors.set(i, mirror)
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
}
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload)
}
}
private async recordActivity(frame: Frame, appId: string | null): Promise<void> {
if (frame.op === OP_HANDSHAKE) return
const data = parseFramePayload(frame)
if (data?.cmd !== 'SET_ACTIVITY') return
const activity = (data.args as { activity?: Record<string, unknown> })?.activity ?? {}
const rawAssets = activity.assets as Record<string, string> | undefined
const assets: ActivityAssets | null = rawAssets
? {
largeImage: await resolveAssetImage(rawAssets.large_image, appId),
largeText: rawAssets.large_text ?? null,
smallImage: await resolveAssetImage(rawAssets.small_image, appId),
smallText: rawAssets.small_text ?? null
}
: null
const rawTimestamps = activity.timestamps as Record<string, number> | undefined
const timestamps: ActivityTimestamps | null = rawTimestamps
? {
start: rawTimestamps.start ?? null,
end: rawTimestamps.end ?? null
}
: null
const rawButtons = activity.buttons as Array<{ label: string; url: string }> | undefined
const buttons: ActivityButton[] = Array.isArray(rawButtons)
? rawButtons.map((b) => ({ label: b.label, url: b.url }))
: []
this.lastActivity = {
app: (activity.name as string) ?? null,
details: (activity.details as string) ?? null,
state: (activity.state as string) ?? null,
assets,
timestamps,
buttons,
at: Date.now()
}
this.emitStatus()
}
private emitStatus(): void {
this.emit('status', this.getStatus())
}
}
export const relay = new RpcRelay()
+9
View File
@@ -0,0 +1,9 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { RelayApi } from './index'
declare global {
interface Window {
electron: ElectronAPI
api: RelayApi
}
}
+35
View File
@@ -0,0 +1,35 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { RelayStatus } from '../main/relay'
const api = {
getStatus: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:get-status'),
start: (): Promise<void> => ipcRenderer.invoke('relay:start'),
stop: (): Promise<void> => ipcRenderer.invoke('relay:stop'),
getAutostart: (): Promise<boolean> => ipcRenderer.invoke('relay:get-autostart'),
setAutostart: (enabled: boolean): Promise<boolean> =>
ipcRenderer.invoke('relay:set-autostart', enabled),
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
ipcRenderer.on('relay:status', listener)
return () => ipcRenderer.removeListener('relay:status', listener)
}
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}
export type RelayApi = typeof api
+5
View File
@@ -0,0 +1,5 @@
@import 'tailwindcss';
body {
user-select: none;
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Discord RPC Relay</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:"
/>
<link href="./assets/main.css" type="text/css" rel="stylesheet" />
</head>
<body class="bg-zinc-900 text-zinc-100 font-sans antialiased">
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+255
View File
@@ -0,0 +1,255 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, Settings } from 'lucide-react'
import type { RelayStatus } from '../../main/relay'
import { InstanceRow } from './components/InstanceRow'
import { Toggle } from './components/Toggle'
import { Button, LinkButton } from './components/Button'
const EMPTY_STATUS: RelayStatus = {
running: false,
unsupported: false,
instances: [],
connectedClients: [],
lastActivity: null,
error: null
}
function formatElapsed(start: number | null, end: number | null): string | null {
const now = Date.now()
if (end && end > now) {
const remaining = Math.max(0, end - now)
return `${formatDuration(remaining)} left`
}
if (start && start <= now) {
return `${formatDuration(now - start)} elapsed`
}
return null
}
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
function ActivityPreview({
activity
}: {
activity: RelayStatus['lastActivity']
}): React.JSX.Element | null {
if (!activity) return null
const { app, details, state, assets, timestamps, buttons, at } = activity
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
return (
<div className="flex flex-col gap-3">
<div className="flex gap-3">
<div className="relative shrink-0 w-16 h-16">
{assets?.largeImage ? (
<img
src={assets.largeImage}
alt={assets.largeText ?? app ?? ''}
title={assets.largeText ?? undefined}
className="w-16 h-16 rounded-lg object-cover bg-zinc-700"
/>
) : (
<div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs">
{app ? app.slice(0, 2).toUpperCase() : '—'}
</div>
)}
{assets?.smallImage && (
<img
src={assets.smallImage}
alt={assets.smallText ?? ''}
title={assets.smallText ?? undefined}
className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full ring-2 ring-zinc-800 object-cover bg-zinc-700"
/>
)}
</div>
<div className="flex flex-col justify-center min-w-0">
{app && <div className="text-sm font-medium text-zinc-100 truncate">{app}</div>}
{details && <div className="text-sm text-zinc-200 truncate">{details}</div>}
{state && <div className="text-sm text-zinc-400 truncate">{state}</div>}
{elapsed && <div className="text-xs text-zinc-500 mt-0.5">{elapsed}</div>}
</div>
</div>
{buttons.length > 0 && (
<div className="flex gap-2">
{buttons.map((button, i) => (
<LinkButton key={i} href={button.url} className="flex-1 text-xs py-1.5 px-2">
{button.label}
</LinkButton>
))}
</div>
)}
<div className="text-xs text-zinc-500">
Last mirrored at {new Date(at).toLocaleTimeString()}
</div>
</div>
)
}
export function App(): React.JSX.Element {
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
const [loading, setLoading] = useState(false)
const [autostart, setAutostart] = useState(false)
const [view, setView] = useState<'main' | 'settings'>('main')
useEffect(() => {
void (async () => {
setStatus(await window.api.getStatus())
setAutostart(await window.api.getAutostart())
})()
return window.api.onStatus(setStatus)
}, [])
const toggleRelay = async (): Promise<void> => {
setLoading(true)
try {
if (status.running) {
await window.api.stop()
} else {
await window.api.start()
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const onToggleMirror = async (index: number, enabled: boolean): Promise<void> => {
setStatus(await window.api.setMirrorEnabled(index, enabled))
}
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
setAutostart(await window.api.setAutostart(enabled))
}
if (status.unsupported) {
return (
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<p className="text-sm text-zinc-400">Windows is not supported.</p>
</div>
)
}
if (view === 'settings') {
return (
<div className="flex flex-col h-screen p-5 gap-4">
<header className="flex items-center gap-3">
<Button
variant="ghost"
onClick={() => setView('main')}
aria-label="Back"
className="p-1.5"
>
<ChevronLeft className="w-5 h-5" />
</Button>
<h1 className="text-lg font-semibold">Settings</h1>
</header>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm">Start on login</span>
<span className="text-xs text-zinc-500">Launch automatically when you sign in</span>
</div>
<Toggle checked={autostart} onChange={onToggleAutostart} />
</section>
</div>
)
}
return (
<div className="flex flex-col h-screen p-5 gap-4">
<header className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<Button
variant="ghost"
onClick={() => setView('settings')}
aria-label="Settings"
className="p-1.5"
>
<Settings className="w-5 h-5" />
</Button>
</header>
{status.error && (
<div className="rounded-lg bg-red-950 border border-red-800 text-red-200 text-sm px-3 py-2">
{status.error}
</div>
)}
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm">Relay</span>
<span className={`text-xs ${status.running ? 'text-emerald-400' : 'text-zinc-500'}`}>
{status.running ? 'Running' : 'Stopped'}
</span>
</div>
<Toggle checked={status.running} onChange={() => toggleRelay()} disabled={loading} />
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Discord Instances</h2>
<div className="flex flex-col gap-1.5 text-sm">
{status.instances.length > 0 ? (
status.instances.map((instance) => (
<InstanceRow
key={instance.index}
instance={instance}
onToggleMirror={onToggleMirror}
/>
))
) : (
<div className="text-zinc-500">No Discord instances detected</div>
)}
</div>
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Connected RPC Clients</h2>
<div className="flex flex-col gap-1.5 text-sm">
{status.connectedClients.length > 0 ? (
status.connectedClients.map((client) => (
<div key={client.id} className="flex items-center justify-between gap-2">
<span className="text-zinc-300">
{client.process
? `${client.process.name} (pid ${client.process.pid})`
: 'unknown process'}
</span>
</div>
))
) : (
<div className="text-zinc-500">No clients connected</div>
)}
</div>
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Last Mirrored Activity</h2>
{status.lastActivity ? (
<ActivityPreview activity={status.lastActivity} />
) : (
<div className="text-sm text-zinc-300">No activity yet</div>
)}
</section>
<p className="text-xs text-zinc-500 mt-auto">
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
the change.
</p>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import type { ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react'
type Variant = 'primary' | 'danger' | 'ghost' | 'subtle'
const VARIANT_CLASSES: Record<Variant, string> = {
primary: 'bg-emerald-600 hover:bg-emerald-500 text-white',
danger: 'bg-red-600 hover:bg-red-500 text-white',
ghost: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800',
subtle: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100'
}
const BASE_CLASSES =
'rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
active?: boolean
}
export function Button({
variant = 'primary',
active = false,
className = '',
children,
...rest
}: ButtonProps): React.JSX.Element {
const activeClasses = active ? 'bg-zinc-700 text-zinc-100' : VARIANT_CLASSES[variant]
return (
<button className={`${BASE_CLASSES} ${activeClasses} ${className}`} {...rest}>
{children}
</button>
)
}
interface LinkButtonProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
variant?: Variant
}
export function LinkButton({
variant = 'subtle',
className = '',
children,
...rest
}: LinkButtonProps): React.JSX.Element {
return (
<a
target="_blank"
rel="noreferrer"
className={`${BASE_CLASSES} ${VARIANT_CLASSES[variant]} text-center truncate ${className}`}
{...rest}
>
{children}
</a>
)
}
@@ -0,0 +1,62 @@
import { Info } from 'lucide-react'
import type { RelayInstance } from '../../../main/relay'
import { Toggle } from './Toggle'
function shortPath(p: string): string {
return p.split('/').pop() ?? p
}
interface Props {
instance: RelayInstance
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
}
export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element {
const procLabel = instance.process
? `${instance.process.name} (pid ${instance.process.pid})`
: 'unknown process'
const info = (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={instance.isPrimary ? 'text-zinc-300' : ''}>
{shortPath(instance.path)}
</span>
{instance.isPrimary ? (
<span className="text-xs text-emerald-400">primary</span>
) : (
<span className={`text-xs ${instance.enabled ? 'text-sky-400' : 'text-zinc-500'}`}>
mirror #{instance.index}
</span>
)}
</div>
<span className="text-xs text-zinc-500">{procLabel}</span>
</div>
)
if (instance.isPrimary) {
return (
<div className="flex items-center justify-between gap-2">
{info}
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
<title>
The primary instance gets full passthrough and can&apos;t be turned off. It&apos;s
always the Discord instance that started first (discord-ipc-0). To make a different
instance primary, close this one first so the other claims that slot, then restart the
relay.
</title>
</Info>
</div>
)
}
return (
<div className="flex items-center justify-between gap-2">
{info}
<Toggle
checked={instance.enabled}
onChange={(checked) => onToggleMirror(instance.index, checked)}
/>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
interface Props {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
}
export function Toggle({ checked, onChange, disabled = false }: Props): React.JSX.Element {
return (
<label
className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
>
<input
type="checkbox"
className="sr-only peer"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<div className="w-10 h-6 bg-zinc-600 rounded-full peer-checked:bg-emerald-600 transition-colors" />
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4" />
</label>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>
)
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/preload/*.d.ts"],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx"
}
}