diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 7a0c94b..44b7320 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -13,7 +13,7 @@ import { CurrentUser, Friend, Game, PresencePlatform, PresenceState, WebService import { NintendoAccountSessionTokenJwtPayload, NintendoAccountUser } from '../../api/na.js'; import { DiscordPresence } from '../../discord/types.js'; import { getDiscordRpcClients } from '../../discord/rpc.js'; -import { defaultTitle } from '../../discord/titles.js'; +import { default_client } from '../../discord/titles.js'; import type { FriendProps } from '../browser/friend/index.js'; import type { DiscordSetupProps } from '../browser/discord/index.js'; import type { AddFriendProps } from '../browser/add-friend/index.js'; @@ -181,7 +181,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { for (const client of await getDiscordRpcClients()) { try { - await client.connect(defaultTitle.client); + await client.connect(default_client); if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user); } finally { await client.destroy(); diff --git a/src/cli/util/discord-rpc.ts b/src/cli/util/discord-rpc.ts index 46fdad3..2244143 100644 --- a/src/cli/util/discord-rpc.ts +++ b/src/cli/util/discord-rpc.ts @@ -1,6 +1,6 @@ import type { Arguments as ParentArguments } from './index.js'; import { DiscordRpcClient, getAllIpcSockets } from '../../discord/rpc.js'; -import { defaultTitle } from '../../discord/titles.js'; +import { default_client } from '../../discord/titles.js'; import createDebug from '../../util/debug.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; @@ -16,8 +16,6 @@ export function builder(yargs: Argv) { type Arguments = YargsArguments>; -const CLIENT_ID = defaultTitle.client; - export async function handler(argv: ArgumentsCamelCase) { const sockets = await getAllIpcSockets(); @@ -26,7 +24,7 @@ export async function handler(argv: ArgumentsCamelCase) { for (const [id, socket] of sockets) { const client = new DiscordRpcClient({ transport: 'ipc', ipc_socket: socket }); - await client.connect(CLIENT_ID); + await client.connect(default_client); debug('[%d] Connected', id); if (client.application) { diff --git a/src/cli/util/export-discord-titles.ts b/src/cli/util/export-discord-titles.ts index b337fe3..c2b9792 100644 --- a/src/cli/util/export-discord-titles.ts +++ b/src/cli/util/export-discord-titles.ts @@ -2,7 +2,7 @@ import { fetch } from 'undici'; import type { Arguments as ParentArguments } from './index.js'; import createDebug from '../../util/debug.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; -import { titles as unsorted_titles } from '../../discord/titles.js'; +import { default_client, titles as unsorted_titles } from '../../discord/titles.js'; import { DiscordApplicationRpc, getDiscordApplicationRpc } from './discord-activity.js'; import { Title } from '../../discord/types.js'; @@ -153,13 +153,13 @@ async function getGroupedTitlesJson(exclude_discord_configuration = false, inclu }[] = []; for (const title of titles) { - let client = clients.find(c => c.id === title.client); + let client = clients.find(c => c.id === (title.client ?? default_client)); if (!client) { - const application = await getDiscordApplicationRpc(title.client); + const application = await getDiscordApplicationRpc(title.client ?? default_client); client = { - id: title.client, + id: title.client ?? default_client, application, titles: [], }; diff --git a/src/cli/util/validate-discord-titles.ts b/src/cli/util/validate-discord-titles.ts index 66487f1..8d2f45f 100644 --- a/src/cli/util/validate-discord-titles.ts +++ b/src/cli/util/validate-discord-titles.ts @@ -47,7 +47,7 @@ export async function handler(argv: ArgumentsCamelCase) { warn('Invalid title ID, must be lowercase hex', title.id); else warn('Invalid title ID', title.id); } - if (!title.client.match(/^\d{16,}$/)) warn('Invalid Discord client ID', title.id, title.client); + if (title.client && !title.client.match(/^\d{16,}$/)) warn('Invalid Discord client ID', title.id, title.client); if (has_errors) continue; diff --git a/src/discord/rpc.ts b/src/discord/rpc.ts index fef52cc..6ee248c 100644 --- a/src/discord/rpc.ts +++ b/src/discord/rpc.ts @@ -56,6 +56,29 @@ export async function findDiscordRpcClient( // Patches discord-rpc to allow using a specific socket. // +export interface DiscordRpcClient { + /** + * Request + * @param {string} cmd Command + * @param {Object} [args={}] Arguments + * @param {string} [evt] Event + * @returns {Promise} + * @private + */ + request(cmd: string, args?: object, evt?: string): Promise; +} + +declare module 'discord-rpc' { + interface Presence { + name?: string; + statusDisplayType?: DiscordApiActivityStatusDisplayType; + stateUrl?: string; + detailsUrl?: string; + largeImageUrl?: string; + smallImageUrl?: string; + } +} + export class DiscordRpcClient extends DiscordRPC.Client { constructor(options?: DiscordRPC.RPCClientOptions & { ipc_socket?: net.Socket; @@ -74,6 +97,149 @@ export class DiscordRpcClient extends DiscordRPC.Client { this.transport.on('message', this._onRpcMessage.bind(this)); } } + + setActivity(args: DiscordRPC.Presence, pid = process.pid) { + const activity: DiscordRpcActivity = { + name: args.name, + type: DiscordApiActivityType.PLAYING, + status_display_type: args.statusDisplayType, + state: args.state, + state_url: args.stateUrl, + details: args.details, + details_url: args.detailsUrl, + buttons: args.buttons, + instance: !!args.instance, + }; + + if (args.startTimestamp || args.endTimestamp) { + activity.timestamps = { + start: args.startTimestamp instanceof Date ? Math.round(args.startTimestamp.getTime()) : args.startTimestamp, + end: args.endTimestamp instanceof Date ? Math.round(args.endTimestamp.getTime()) : args.endTimestamp, + }; + if (typeof activity.timestamps.start === 'number' && activity.timestamps.start > 2147483647000) { + throw new RangeError('timestamps.start must fit into a unix timestamp'); + } + if (typeof activity.timestamps.end === 'number' && activity.timestamps.end > 2147483647000) { + throw new RangeError('timestamps.end must fit into a unix timestamp'); + } + } + + if (args.largeImageKey || args.largeImageText || + args.smallImageKey || args.smallImageText + ) { + activity.assets = { + large_image: args.largeImageKey, + large_text: args.largeImageText, + large_url: args.largeImageUrl, + small_image: args.smallImageKey, + small_text: args.smallImageText, + small_url: args.smallImageUrl, + }; + } + + if (args.partySize || args.partyId || args.partyMax) { + activity.party = { + id: args.partyId, + size: args.partySize || args.partyMax ? [args.partySize ?? 0, args.partyMax ?? 0] : undefined, + }; + } + + if (args.matchSecret || args.joinSecret || args.spectateSecret) { + activity.secrets = { + match: args.matchSecret, + join: args.joinSecret, + spectate: args.spectateSecret, + }; + } + + return this.setActivityRaw(activity, pid); + } + + setActivityRaw(activity: DiscordRpcActivity, pid = process.pid) { + debug('set activity', activity); + + return this.request('SET_ACTIVITY', { + pid, + activity, + }); + } +} + +type DiscordRpcActivity = Partial>; + +interface DiscordApiActivity { + name: string; + type: DiscordApiActivityType; + url?: string; + created_at: number; + timestamps?: DiscordApiActivityTimestamps; + application_id?: string; + status_display_type?: DiscordApiActivityStatusDisplayType; + details?: string; + details_url?: string; + state?: string; + state_url?: string; + emoji?: DiscordApiActivityEmoji; + party?: DiscordApiActivityParty; + assets?: DiscordApiActivityAssets; + secrets?: DiscordApiActivitySecrets; + instance?: boolean; + flags?: number; + buttons?: DiscordApiActivityButton[]; +} +enum DiscordApiActivityType { + PLAYING = 0, + STREAMING = 1, + LISTENING = 2, + WATCHING = 3, + CUSTOM = 4, + COMPETING = 5, +} +export enum DiscordApiActivityStatusDisplayType { + NAME = 0, + STATE = 1, + DETAILS = 2, +} +interface DiscordApiActivityTimestamps { + start?: number; + end?: number; +} +interface DiscordApiActivityEmoji { + name: string; + id?: string; + animated?: boolean; +} +interface DiscordApiActivityParty { + id?: string; + size?: [number, number]; +} +interface DiscordApiActivityAssets { + large_image?: string; + large_text?: string; + large_url?: string; + small_image?: string; + small_text?: string; + small_url?: string; +} +interface DiscordApiActivitySecrets { + join?: string; + spectate?: string; + match?: string; +} +enum DiscordApiActivityFlags { + INSTANCE = 1 << 0, + JOIN = 1 << 1, + SPECTATE = 1 << 2, + JOIN_REQUEST = 1 << 3, + SYNC = 1 << 4, + PLAY = 1 << 5, + PARTY_PRIVACY_FRIENDS = 1 << 6, + PARTY_PRIVACY_VOICE_CHANNEL = 1 << 7, + EMBEDDED = 1 << 8, +} +interface DiscordApiActivityButton { + label: string; + url: string; } class IpcTransport extends BaseIpcTransport { diff --git a/src/discord/titles.ts b/src/discord/titles.ts index 3dbc2a4..a8b5dda 100644 --- a/src/discord/titles.ts +++ b/src/discord/titles.ts @@ -2,19 +2,20 @@ import { Title } from './types.js'; import * as publishers from './titles/index.js'; import { PresencePlatform } from '../api/coral-types.js'; -export const defaultTitle: Title = { - id: '0000000000000000', - client: '950883021165330493', - titleName: true, - showPlayingOnline: true, - showActiveEvent: true, -}; - export const platform_clients: Record = { [PresencePlatform.NX]: '950883021165330493', [PresencePlatform.OUNCE]: '1358060657957928970', }; +export const default_client = platform_clients[PresencePlatform.NX]; + +export const defaultTitle: Title = { + id: '0000000000000000', + titleName: true, + showPlayingOnline: true, + showActiveEvent: true, +}; + export const titles: Title[] = []; for (const [publisher, m] of Object.entries(publishers)) { diff --git a/src/discord/types.ts b/src/discord/types.ts index e6d3c74..1bcca46 100644 --- a/src/discord/types.ts +++ b/src/discord/types.ts @@ -27,7 +27,9 @@ export interface DiscordPresence { type SystemModuleTitleId = `01000000000000${string}`; type SystemDataTitleId = `01000000000008${string}`; type SystemAppletTitleId = `0100000000001${string}`; -type ApplicationTitleId = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`; +type ApplicationTitleIdNx = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`; +type ApplicationTitleIdOunce = `0400${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`; +type ApplicationTitleId = ApplicationTitleIdNx | ApplicationTitleIdOunce; export interface Title { /** @@ -39,12 +41,18 @@ export interface Title { /** * Discord client ID */ - client: string; + client?: string; + + /** + * Activity name. This overrides the application's name that will appear under the user's name after "Playing ". + */ + setActivityName?: boolean; + activityName?: string; /** * Title name to show in Discord. This is *not* the name that will appear under the user's name after "Playing ". * - * If this is set to true the title's name from znc will be used. + * If this is set to true the title's name from coral will be used. * If this is set to false (default) no title name will be set. This should be used when a specific Discord client for the title is used. * If this is set to a string it will be used as the title name. * @@ -52,7 +60,7 @@ export interface Title { */ titleName?: string | boolean; /** - * By default the title's icon from znc will be used. (No icons need to be uploaded to Discord.) + * By default the title's icon from coral will be used. (No icons need to be uploaded to Discord.) */ largeImageKey?: string; largeImageText?: string; diff --git a/src/discord/util.ts b/src/discord/util.ts index 134b508..ce2b323 100644 --- a/src/discord/util.ts +++ b/src/discord/util.ts @@ -1,10 +1,11 @@ import DiscordRPC from 'discord-rpc'; -import { PresenceGame, PresenceState } from '../api/coral-types.js'; -import { defaultTitle, platform_clients, titles } from './titles.js'; +import { PresenceGame, PresencePlatform, PresenceState } from '../api/coral-types.js'; +import { default_client, defaultTitle, platform_clients, titles } from './titles.js'; import createDebug from '../util/debug.js'; import { product, version } from '../util/product.js'; import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js'; import { DiscordPresence, DiscordPresenceContext, DiscordPresencePlayTime } from './types.js'; +import { DiscordApiActivityStatusDisplayType } from './rpc.js'; const debug = createDebug('nxapi:discord'); @@ -44,9 +45,18 @@ export function getDiscordPresence( const activity = new DiscordActivity(); + if (title.setActivityName) { + activity.name = title.activityName ?? game.name; + } else if (title.titleName) { + // If this is set it/the title name is used as the details field + activity.statusDisplayType = DiscordApiActivityStatusDisplayType.DETAILS; + } + activity.details = text[0]; activity.state = text[1]; + activity.platform = context?.platform; + activity.setLargeImage(title.largeImageKey ?? game.imageUri, title.largeImageText); if (title.smallImageKey) { @@ -78,7 +88,7 @@ export function getDiscordPresence( return { id: (title !== defaultTitle ? title : null)?.client || (typeof context?.platform !== 'undefined' && platform_clients[context.platform]) || - defaultTitle.client, + defaultTitle.client || default_client, title: titleid, config: title, activity, @@ -87,6 +97,8 @@ export function getDiscordPresence( } export class DiscordActivity implements DiscordRPC.Presence { + name?: string = undefined; + statusDisplayType?: DiscordApiActivityStatusDisplayType | undefined; details?: string = undefined; state?: string = undefined; largeImageKey?: string = undefined; @@ -95,13 +107,24 @@ export class DiscordActivity implements DiscordRPC.Presence { smallImageText?: string = undefined; buttons: { label: string; url: string; }[] = []; + platform?: PresencePlatform; + constructor() { // } + get large_image_default_text() { + let text = product; + + if (this.platform === PresencePlatform.NX) text = 'Playing on Nintendo Switch | ' + text; + if (this.platform === PresencePlatform.OUNCE) text = 'Playing on Nintendo Switch 2 | ' + text; + + return text; + } + setLargeImage(key: string, text?: string) { this.largeImageKey = key; - this.largeImageText = text ? text + ' | ' + product : product; + this.largeImageText = text ? text + ' | ' + this.large_image_default_text : this.large_image_default_text; } setSmallImage(key: string, text?: string) { @@ -174,7 +197,7 @@ export function getInactiveDiscordPresence( ): DiscordPresence { return { id: (typeof context?.platform !== 'undefined' && platform_clients[context.platform]) || - defaultTitle.client, + defaultTitle.client || default_client, title: null, activity: { state: 'Not playing',