Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 72 additions & 16 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import {
rescanLanServers,
startLanDiscovery,
} from "./lanDiscovery";
import { readNowPlaying } from "./nowPlaying";
import {
createPendingUpdate,
type DownloadOptions,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, DetectedApp>();

/** 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<string, unknown> | 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<string>();

Expand Down Expand Up @@ -310,22 +353,23 @@ 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();
}

function stopGameDetection(): void {
gameDetector?.stop();
gameDetector = null;
presenceBoard?.setDetected([]);
detectedShown = [];
pushDetected();
}
let rpcState: RpcHostState = "off";
let rpcHolder: Holder | null = null;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -406,6 +445,8 @@ async function stopRichPresence(): Promise<void> {
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;
Expand Down Expand Up @@ -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) => {
Expand Down
173 changes: 173 additions & 0 deletions electron/nowPlaying.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string> = {
"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<string, string> = {
"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<string, unknown>;
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<NowPlaying | null> {
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<NowPlaying | null> {
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<NowPlaying | null> {
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<NowPlaying | null> {
if (process.platform === "win32") return readWindows();
if (process.platform === "linux") return readLinux();
if (process.platform === "darwin") return readMac(running);
return null;
}
9 changes: 9 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
return ipcRenderer.invoke("now-playing-get");
},

setAddonActivity(addonId: string, card: unknown): Promise<void> {
return ipcRenderer.invoke("addon-activity-set", addonId, card);
},

/* Known games spotted by their program, each asked about once (GRYT-1636). */
getGameDetection(): Promise<unknown> {
return ipcRenderer.invoke("game-detect-get");
Expand Down
7 changes: 5 additions & 2 deletions electron/richPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading