From 8ab19e389694cf3560386b58d32a34fbc0a1388a Mon Sep 17 00:00:00 2001 From: Sivert Date: Wed, 30 Sep 2026 00:33:13 +0200 Subject: [PATCH 1/2] Let addons read what's playing and put a card on your profile (GRYT-1637) Two new addon permissions, agreed to per addon like the others. media reads what the OS media controls say is playing: Windows' media session, MPRIS on Linux, and on macOS Spotify and Music asked directly, since macOS keeps its own Now Playing to Apple. activity lets an addon put a card like "Listening to" on your profile, on every server. Nothing reads the media controls unless an addon with media is on and listening, and a stopped addon's card comes down with it. The card goes through the same board as games, so a game still wins over it, and it works with Rich Presence off. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + electron/main.ts | 88 +++++++-- electron/nowPlaying.ts | 173 ++++++++++++++++++ electron/preload.ts | 9 + electron/richPresence.ts | 7 +- package.json | 1 + scripts/check-now-playing.mjs | 51 ++++++ src/components/gameDetectPrompt.tsx | 2 +- src/lib/electron.ts | 21 +++ src/packages/addons/src/addonWorker.ts | 21 +++ src/packages/addons/src/capabilities.ts | 5 +- src/packages/addons/src/pluginHost.ts | 56 ++++++ src/packages/addons/src/workerProtocol.ts | 4 + .../settings/src/components/richPresence.tsx | 6 +- 14 files changed, 422 insertions(+), 23 deletions(-) create mode 100644 electron/nowPlaying.ts create mode 100644 scripts/check-now-playing.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 123a982c..81c29f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,7 @@ jobs: - run: yarn test:discord-ipc - run: yarn test:rich-presence - run: yarn test:game-detect + - run: yarn test:now-playing - run: yarn test:presence-helper - run: yarn test:helper-startup - run: yarn test:game-list diff --git a/electron/main.ts b/electron/main.ts index a6238565..28aff072 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -120,6 +120,7 @@ import { rescanLanServers, startLanDiscovery, } from "./lanDiscovery"; +import { readNowPlaying } from "./nowPlaying"; import { createPendingUpdate, type DownloadOptions, @@ -134,7 +135,7 @@ import { type ProcessWatcher, readWatchList, } from "./processWatcher"; -import { createPresenceBoard, type PresenceBoard } from "./richPresence"; +import { createPresenceBoard, type DetectedApp, type PresenceBoard } from "./richPresence"; import { isNativeScreenCaptureAvailable, startNativeScreenCapture, @@ -266,6 +267,48 @@ let programIndex: ProgramIndex = { byProgram: new Map(), known: new Map() }; function detectedName(key: string): string | null { return programIndex.known.get(key)?.name ?? (key.startsWith("app:") ? null : nameForApp(key)); } + +/* Games and apps from the detector, and cards addons set, go to the board together. */ +let detectedShown: DetectedApp[] = []; +/** One card per addon that holds the `activity` permission (GRYT-1637). */ +const addonCards = new Map(); + +/** The one board every card goes through. An addon's card needs it even with Rich Presence off. */ +function ensurePresenceBoard(): PresenceBoard { + if (presenceBoard) return presenceBoard; + const board = createPresenceBoard({ + nameForApp, + onChange: (card) => mainWindow?.webContents.send("rich-presence-changed", card), + }); + board.setHidden(readHiddenApps().map((app) => app.id)); + presenceBoard = board; + return board; +} + +function pushDetected(): void { + const cards = [...detectedShown, ...addonCards.values()]; + (cards.length ? ensurePresenceBoard() : presenceBoard)?.setDetected(cards); +} + +const ADDON_CARD_TYPES = new Set(["playing", "listening", "watching", "using"]); +const cardLine = (value: unknown, max: number) => + typeof value === "string" ? [...value].map((c) => (c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127 ? " " : c)).join("").trim().slice(0, max) || undefined : undefined; + +/** An addon's card, off the renderer and so not trusted: bad fields are dropped, a bad card is none. */ +function readAddonCard(addonId: string, raw: unknown): DetectedApp | null { + const r = raw as Record | null; + const name = cardLine(r?.name, 64); + if (!r || !name) return null; + const since = typeof r.startedAt === "number" && Number.isFinite(r.startedAt) ? r.startedAt : Date.now(); + return { + appId: `addon:${addonId.replace(/[^a-z0-9._-]/gi, "").slice(0, 40)}`, + since, + name, + type: ADDON_CARD_TYPES.has(r.type as string) ? (r.type as DetectedApp["type"]) : "playing", + details: cardLine(r.details, 128), + state: cardLine(r.state, 128), + }; +} /** Asked about and not answered yet. Kept until answered, even after the game closes. */ const pendingAsks = new Set(); @@ -310,14 +353,14 @@ function startGameDetection(): void { pendingAsks.add(appId); mainWindow?.webContents.send("game-detect-changed", detectStatus()); }, - onChange: (shown) => - presenceBoard?.setDetected( - shown.map((app) => ({ - ...app, - name: detectedName(app.appId), - type: programIndex.known.get(app.appId)?.kind === "app" ? "using" : undefined, - })), - ), + onChange: (shown) => { + detectedShown = shown.map((app) => ({ + ...app, + name: detectedName(app.appId), + type: programIndex.known.get(app.appId)?.kind === "app" ? "using" : undefined, + })); + pushDetected(); + }, }); gameDetector.start(); } @@ -325,7 +368,8 @@ function startGameDetection(): void { function stopGameDetection(): void { gameDetector?.stop(); gameDetector = null; - presenceBoard?.setDetected([]); + detectedShown = []; + pushDetected(); } let rpcState: RpcHostState = "off"; let rpcHolder: Holder | null = null; @@ -371,12 +415,7 @@ function startRichPresence(): void { void maybeRefreshDetectableList({ userDataDir: app.getPath("userData") }) .then((index) => { downloadedDetectableIndex = index; }) .catch((err: unknown) => console.error("rich presence: game list refresh failed", err)); - const board = createPresenceBoard({ - nameForApp, - onChange: (card) => mainWindow?.webContents.send("rich-presence-changed", card), - }); - board.setHidden(readHiddenApps().map((app) => app.id)); - presenceBoard = board; + const board = ensurePresenceBoard(); // gryt-helper when it runs, since it got to the socket at login; the in-app socket otherwise. presenceSource = createPresenceSource({ onActivity: (event) => board.update(event), @@ -406,6 +445,8 @@ async function stopRichPresence(): Promise { presenceBoard?.reset(); presenceBoard?.stop(); presenceBoard = null; + // An addon's card outlives Rich Presence being turned off. + if (addonCards.size) pushDetected(); rpcState = "off"; rpcHolder = null; rpcVia = null; @@ -3121,6 +3162,21 @@ if (!gotSingleInstanceLock) { return presenceHelperStatus(); }); + /* Addons with `media`: what the OS media controls say is playing, or null. */ + ipcMain.handle("now-playing-get", async () => { + const running = process.platform === "darwin" ? await listRunningExecutables() : []; + return readNowPlaying(running).catch(() => null); + }); + + /* Addons with `activity`: their card, or null to take it down. */ + ipcMain.handle("addon-activity-set", (_event, addonId: unknown, card: unknown) => { + if (typeof addonId !== "string" || !addonId) return; + const next = card === null ? null : readAddonCard(addonId, card); + if (next) addonCards.set(addonId, next); + else addonCards.delete(addonId); + pushDetected(); + }); + ipcMain.handle("game-detect-get", () => detectStatus()); ipcMain.handle("game-detect-set-enabled", (_event, allow: unknown) => { diff --git a/electron/nowPlaying.ts b/electron/nowPlaying.ts new file mode 100644 index 00000000..6b75deb1 --- /dev/null +++ b/electron/nowPlaying.ts @@ -0,0 +1,173 @@ +/** What's playing, read from the OS media controls (GRYT-1637), for a "Listening to" card. + Windows reads its media session, Linux MPRIS, and macOS asks Spotify and Music directly. */ + +import { execFile } from "child_process"; + +export interface NowPlaying { + /** The player, as a key for asking once: `app:spotify`. */ + key: string; + /** The player's name, like Spotify. */ + player: string; + title: string; + artist?: string; + album?: string; +} + +function run(command: string, args: string[], timeout = 4_000): Promise { + return new Promise((resolve) => { + execFile(command, args, { timeout, windowsHide: true, maxBuffer: 256 * 1024 }, (err, stdout) => + resolve(err ? "" : stdout), + ); + }); +} + +const clean = (value: unknown, max = 128): string | undefined => { + if (typeof value !== "string") return undefined; + const text = [...value].map((c) => (c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127 ? " " : c)).join("").trim().slice(0, max); + return text || undefined; +}; + +/** A player name as a key: `Spotify.exe` and `org.mpris.MediaPlayer2.spotify` both become `app:spotify`. */ +export function playerKey(raw: string): string { + const base = raw + .replace(/^org\.mpris\.MediaPlayer2\./i, "") + .replace(/\.instance[\w.]*$/i, "") + .replace(/!.*$/, "") + .replace(/^.*[\\/]/, "") + .replace(/\.exe$/i, "") + .toLowerCase(); + const known: Record = { + "spotifyab.spotifymusic_zpdnekdrzrea0": "spotify", + "appleinc.applemusicwin_nzyj5cx40ttqa": "music", + chromium: "chrome", + "google-chrome": "chrome", + }; + const name = (known[base] ?? base).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40); + return `app:${name || "player"}`; +} + +const PLAYER_NAMES: Record = { + "app:spotify": "Spotify", + "app:music": "Apple Music", + "app:chrome": "Chrome", + "app:firefox": "Firefox", + "app:msedge": "Edge", + "app:vlc": "VLC", + "app:tidal": "TIDAL", + "app:deezer": "Deezer", +}; + +export function playerName(key: string, fallback?: string): string { + return PLAYER_NAMES[key] ?? clean(fallback, 40) ?? key.replace(/^app:/, ""); +} + +/* ── Windows: the media session every player with media controls reports to ── */ + +const WINDOWS_SCRIPT = ` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Runtime.WindowsRuntime +$asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0] +function Await($op, $type) { $t = $asTask.MakeGenericMethod($type).Invoke($null, @($op)); $t.Wait(-1) | Out-Null; $t.Result } +[Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime] | Out-Null +$mgr = Await ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]) +$s = $mgr.GetCurrentSession() +if ($s) { + $p = Await ($s.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties]) + [pscustomobject]@{ app = $s.SourceAppUserModelId; title = $p.Title; artist = $p.Artist; album = $p.AlbumTitle; status = [string]$s.GetPlaybackInfo().PlaybackStatus } | ConvertTo-Json -Compress +} +`; + +/** One line of JSON from the script above; only a session that is playing counts. */ +export function parseWindows(stdout: string): NowPlaying | null { + try { + const raw = JSON.parse(stdout.trim()) as Record; + if (raw.status !== "Playing") return null; + const title = clean(raw.title); + const app = clean(raw.app, 200); + if (!title || !app) return null; + const key = playerKey(app); + return { key, player: playerName(key, app), title, artist: clean(raw.artist), album: clean(raw.album) }; + } catch { + return null; + } +} + +async function readWindows(): Promise { + const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_SCRIPT], 8_000); + return out ? parseWindows(out) : null; +} + +/* ── Linux: MPRIS over the session bus ───────────────────────────────── */ + +/** The player names on the bus, from `gdbus call ... ListNames`. */ +export function parseMprisNames(stdout: string): string[] { + return [...stdout.matchAll(/'(org\.mpris\.MediaPlayer2\.[^']+)'/g)].map((m) => m[1]); +} + +/** `gdbus call ... GetAll org.mpris.MediaPlayer2.Player`, read with regexes since it's GVariant text. */ +export function parseMprisPlayer(name: string, stdout: string): NowPlaying | null { + if (!/'PlaybackStatus': <'Playing'>/.test(stdout)) return null; + const pick = (field: string) => stdout.match(new RegExp(`'${field}': <(?:\\[)?'((?:[^'\\\\]|\\\\.)*)'`))?.[1]; + const title = clean(pick("xesam:title")); + if (!title) return null; + const key = playerKey(name); + return { key, player: playerName(key, name.replace(/^org\.mpris\.MediaPlayer2\./, "")), title, artist: clean(pick("xesam:artist")), album: clean(pick("xesam:album")) }; +} + +async function readLinux(): Promise { + const names = parseMprisNames( + await run("gdbus", ["call", "--session", "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus", "--method", "org.freedesktop.DBus.ListNames"]), + ); + for (const name of names) { + const out = await run("gdbus", ["call", "--session", "--dest", name, "--object-path", "/org/mpris/MediaPlayer2", "--method", "org.freedesktop.DBus.Properties.GetAll", "org.mpris.MediaPlayer2.Player"]); + const found = parseMprisPlayer(name, out); + if (found) return found; + } + return null; +} + +/* ── macOS: Spotify and Music, asked directly, only while they're running ── */ + +/* + * macOS keeps its system-wide Now Playing to Apple's own apps, so Gryt asks the two + * players people use. The first time, macOS asks whether Gryt may talk to them. + */ +const MAC_PLAYERS: { app: string; key: string }[] = [ + { app: "Spotify", key: "app:spotify" }, + { app: "Music", key: "app:music" }, +]; + +const macScript = (app: string) => ` +if application "${app}" is running then + tell application "${app}" + if player state is playing then + return (name of current track) & (character id 31) & (artist of current track) & (character id 31) & (album of current track) + end if + end tell +end if +return ""`; + +export function parseMac(key: string, stdout: string): NowPlaying | null { + const [title, artist, album] = stdout.trim().split("\u001f"); + const t = clean(title); + if (!t) return null; + return { key, player: playerName(key), title: t, artist: clean(artist), album: clean(album) }; +} + +async function readMac(running: readonly string[]): Promise { + for (const { app, key } of MAC_PLAYERS) { + // Checked against the process list first, so asking never launches a player that's closed. + if (!running.some((p) => p.includes(`/${app}.app/`))) continue; + const found = parseMac(key, await run("osascript", ["-e", macScript(app)])); + if (found) return found; + } + return null; +} + +/** What's playing right now, or null. `running` is the process list the detector already has. */ +export async function readNowPlaying(running: readonly string[] = []): Promise { + if (process.platform === "win32") return readWindows(); + if (process.platform === "linux") return readLinux(); + if (process.platform === "darwin") return readMac(running); + return null; +} diff --git a/electron/preload.ts b/electron/preload.ts index 247d909c..739c6354 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -165,6 +165,15 @@ contextBridge.exposeInMainWorld("electronAPI", { return ipcRenderer.invoke("rich-presence-get"); }, + /* For addons: what the media controls say is playing, and an addon's own card (GRYT-1637). */ + getNowPlaying(): Promise { + return ipcRenderer.invoke("now-playing-get"); + }, + + setAddonActivity(addonId: string, card: unknown): Promise { + return ipcRenderer.invoke("addon-activity-set", addonId, card); + }, + /* Known games spotted by their program, each asked about once (GRYT-1636). */ getGameDetection(): Promise { return ipcRenderer.invoke("game-detect-get"); diff --git a/electron/richPresence.ts b/electron/richPresence.ts index c18b473d..d7fba359 100644 --- a/electron/richPresence.ts +++ b/electron/richPresence.ts @@ -97,8 +97,11 @@ export interface DetectedApp { appId: string; since: number; name?: string | null; - /** "using" for an app like Figma. */ + /** "using" for an app like Figma, "listening" for music. */ type?: CardType; + /** For music: the track, and who it's by. */ + details?: string; + state?: string; } export interface PresenceBoard { @@ -147,7 +150,7 @@ export function createPresenceBoard(options: PresenceBoardOptions): PresenceBoar const found = detected.find((app) => !hidden.has(app.appId)); if (!found) return null; const card = cardFromActivity( - { timestamps: { start: found.since } }, + { timestamps: { start: found.since }, details: found.details, state: found.state }, found.name ?? options.nameForApp(found.appId) ?? UNKNOWN_GAME, found.appId, ); diff --git a/package.json b/package.json index 169bf39b..9c2e4580 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ "test:discord-ipc": "node scripts/check-discord-ipc.mjs", "test:rich-presence": "node scripts/check-rich-presence.mjs", "test:game-detect": "node scripts/check-game-detect.mjs", + "test:now-playing": "node scripts/check-now-playing.mjs", "test:presence-helper": "node scripts/check-presence-helper.mjs", "test:helper-startup": "node scripts/check-helper-startup.mjs", "test:game-list": "node scripts/check-game-list.mjs", diff --git a/scripts/check-now-playing.mjs b/scripts/check-now-playing.mjs new file mode 100644 index 00000000..1e6d6f01 --- /dev/null +++ b/scripts/check-now-playing.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** Reading what's playing from each OS's media controls (GRYT-1637). Only a playing track counts. */ + +import assert from "node:assert/strict"; + +import { parseMac, parseMprisNames, parseMprisPlayer, parseWindows, playerKey } from "../electron/nowPlaying.ts"; + +let failures = 0; +function check(name, run) { + try { + run(); + console.log(` ok ${name}`); + } catch (err) { + failures += 1; + console.error(` FAIL ${name}\n ${err.stack ?? err.message}`); + } +} + +console.log("now playing"); + +check("a player's many names become one key", () => { + assert.equal(playerKey("Spotify.exe"), "app:spotify"); + assert.equal(playerKey("SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify"), "app:spotify"); + assert.equal(playerKey("org.mpris.MediaPlayer2.spotify"), "app:spotify"); + assert.equal(playerKey("org.mpris.MediaPlayer2.firefox.instance_1_42"), "app:firefox"); + assert.equal(playerKey("org.mpris.MediaPlayer2.chromium.instance1234"), "app:chrome"); +}); + +check("Windows: a playing session becomes a track, anything else nothing", () => { + const playing = parseWindows('{"app":"Spotify.exe","title":"Harbour Lights","artist":"Tove","album":"Waves","status":"Playing"}'); + assert.deepEqual(playing, { key: "app:spotify", player: "Spotify", title: "Harbour Lights", artist: "Tove", album: "Waves" }); + assert.equal(parseWindows('{"app":"Spotify.exe","title":"x","status":"Paused"}'), null); + assert.equal(parseWindows(""), null); + assert.equal(parseWindows("not json"), null); +}); + +const GETALL = "({'PlaybackStatus': <'Playing'>, 'Metadata': <{'xesam:title': <'Harbour Lights'>, 'xesam:artist': <['Tove']>, 'xesam:album': <'Waves'>}>},)"; + +check("Linux: MPRIS names and a playing player's track", () => { + assert.deepEqual(parseMprisNames("(['org.freedesktop.DBus', 'org.mpris.MediaPlayer2.spotify', ':1.4'],)"), ["org.mpris.MediaPlayer2.spotify"]); + assert.deepEqual(parseMprisPlayer("org.mpris.MediaPlayer2.spotify", GETALL), { key: "app:spotify", player: "Spotify", title: "Harbour Lights", artist: "Tove", album: "Waves" }); + assert.equal(parseMprisPlayer("org.mpris.MediaPlayer2.spotify", GETALL.replace("Playing", "Paused")), null); +}); + +check("macOS: the osascript line, or nothing when nothing plays", () => { + assert.deepEqual(parseMac("app:music", "Harbour Lights\u001fTove\u001fWaves\n"), { key: "app:music", player: "Apple Music", title: "Harbour Lights", artist: "Tove", album: "Waves" }); + assert.equal(parseMac("app:music", "\n"), null); +}); + +console.log(failures === 0 ? "\nnow playing: ok" : `\nnow playing: ${failures} failed.`); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/components/gameDetectPrompt.tsx b/src/components/gameDetectPrompt.tsx index 166ceb13..d9341fc3 100644 --- a/src/components/gameDetectPrompt.tsx +++ b/src/components/gameDetectPrompt.tsx @@ -25,7 +25,7 @@ export function GameDetectPrompt() { Show {name} on your card? - Gryt spotted it running. People on your servers see its name while you play. + Gryt spotted it. People on your servers see it on your card while it’s on.