Skip to content

Commit c06583c

Browse files
committed
Presence push, Linux probes, collections contributors
Main: push presence-change broadcasts to renderers on game start/exit and enhance presence-heartbeat to accept renderer-supplied currentAppid/currentGameName (or pick from main's runningGames). Add broadcast listener wiring in preload so renderers can trigger immediate heartbeats. Electron/system-profile: improve Linux hardware probing — parse vulkaninfo and lshw for marketing GPU names, prefer vendor-friendly fallbacks, add EDID parsing and read /sys/class/drm edid blobs, and merge xrandr data for accurate display modes. Also enrich NVIDIA probing and avoid leaking pci.ids placeholder labels. Renderer (CollectionsPage): add contributors management UI and dialog, contributor avatar stack, sync prompt for local-only collections, permissions model for collection editing, reorderable collection editor (preserve initial items and permission-based remove/add), and various UI/UX tweaks. Update imports to use new cloud-collections contributor APIs and adjust collection editor data flow. Overall: small API surface changes across main/preload/renderer to support immediate presence updates and richer Linux hardware/display detection, plus feature work for collection collaborators.
1 parent a97ec27 commit c06583c

9 files changed

Lines changed: 1264 additions & 173 deletions

File tree

electron/main.cjs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7057,6 +7057,9 @@ function registerRunningGame(appid, exePath, proc, gameName, showGameName = true
70577057
if (appid) writeLibraryGameMeta(appid, { lastPlayedAt: payload.startedAt })
70587058
if (appid) runningGames.set(appid, payload)
70597059
if (exePath) runningGames.set(exePath, payload)
7060+
// Notify renderers so they can fire an immediate presence heartbeat and the
7061+
// website's "now playing" counter updates without waiting for the 2.5-min tick.
7062+
broadcastPresenceChange({ reason: 'game-started', appid: appid || null, gameName: gameName || null })
70607063
if (gameName || appid) {
70617064
const buttons = appid
70627065
? [
@@ -7113,6 +7116,7 @@ function registerRunningGame(appid, exePath, proc, gameName, showGameName = true
71137116
}
71147117

71157118
clearTrackedPayload()
7119+
broadcastPresenceChange({ reason: 'game-exited', appid: payload.appid || null })
71167120
if (runningGames.size === 0) clearGameRpcActivity()
71177121
if (exitedPid) cleanupOverlayInjection(exitedPid)
71187122
if (injectedRecently && payload.appid) {
@@ -12726,12 +12730,63 @@ ipcMain.handle('uc:playtime-server-totals', (event, { baseUrl } = {}) =>
1272612730

1272712731
// ── Presence heartbeat ────────────────────────────────────────────────────────
1272812732
// Sends a lightweight ping to /api/playtime/heartbeat so the website can show
12729-
// a real-time "Playing now" counter. Called by the renderer every ~2.5 min.
12730-
ipcMain.handle('uc:presence-heartbeat', async (event, { baseUrl, appVersion } = {}) => {
12733+
// real-time "Now online" and "Now playing" counters. Called by the renderer
12734+
// every ~2.5 min, and also whenever a game starts / exits (push-driven so the
12735+
// game state lands on the server immediately).
12736+
function broadcastPresenceChange(detail) {
12737+
try {
12738+
for (const win of BrowserWindow.getAllWindows()) {
12739+
try {
12740+
if (!win.isDestroyed()) {
12741+
win.webContents.send('uc:presence-changed', detail || {})
12742+
}
12743+
} catch { /* swallow per-window send errors */ }
12744+
}
12745+
} catch { /* swallow */ }
12746+
}
12747+
12748+
function pickCurrentRunningGame() {
12749+
if (runningGames.size === 0) return { appid: null, name: null }
12750+
// runningGames is keyed by *both* appid and exePath, so iterating with a
12751+
// Set guarantees we count each payload once. Prefer entries that have an
12752+
// appid (real catalog games) over loose exe-only entries.
12753+
const seen = new Set()
12754+
let exeFallback = null
12755+
for (const payload of runningGames.values()) {
12756+
if (!payload || seen.has(payload)) continue
12757+
seen.add(payload)
12758+
if (payload.appid) {
12759+
return {
12760+
appid: String(payload.appid),
12761+
name: payload.name || payload.title || null,
12762+
}
12763+
}
12764+
if (!exeFallback) exeFallback = payload
12765+
}
12766+
if (exeFallback) {
12767+
return { appid: null, name: exeFallback.name || exeFallback.title || null }
12768+
}
12769+
return { appid: null, name: null }
12770+
}
12771+
12772+
ipcMain.handle('uc:presence-heartbeat', async (event, { baseUrl, appVersion, currentAppid, currentGameName } = {}) => {
1273112773
const win = BrowserWindow.fromWebContents(event.sender)
1273212774
if (!win || win.isDestroyed()) return { ok: false, error: 'no-window' }
1273312775
try {
12734-
const payload = { appVersion: appVersion || getAppVersion() }
12776+
// Renderer can override (e.g. when it knows the user just launched a
12777+
// specific game), otherwise we pick from main's runningGames Map.
12778+
let effectiveAppid = currentAppid && String(currentAppid).trim() ? String(currentAppid).trim() : null
12779+
let effectiveGameName = currentGameName && String(currentGameName).trim() ? String(currentGameName).trim() : null
12780+
if (!effectiveAppid) {
12781+
const current = pickCurrentRunningGame()
12782+
effectiveAppid = current.appid
12783+
if (!effectiveGameName) effectiveGameName = current.name
12784+
}
12785+
const payload = {
12786+
appVersion: appVersion || getAppVersion(),
12787+
currentAppid: effectiveAppid,
12788+
currentGameName: effectiveGameName,
12789+
}
1273512790
const response = await fetchWithSession(win.webContents.session, baseUrl, '/api/playtime/heartbeat', {
1273612791
method: 'POST',
1273712792
headers: { 'Content-Type': 'application/json' },

electron/preload.cjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,21 @@ contextBridge.exposeInMainWorld('ucPlaytime', {
357357
},
358358
})
359359

360-
// Presence heartbeat — lets the website show a real-time "Playing now" counter
360+
// Presence heartbeat — lets the website show real-time "Now online" / "Now
361+
// playing" counters. The renderer fires this on the timer; main listens for
362+
// the broadcast below so it can push an extra heartbeat whenever a game
363+
// starts or exits (so the counter updates without waiting for the tick).
361364
contextBridge.exposeInMainWorld('ucPresence', {
362-
heartbeat: (baseUrl, appVersion) => ipcRenderer.invoke('uc:presence-heartbeat', { baseUrl, appVersion }),
365+
heartbeat: (baseUrl, appVersion, opts) => ipcRenderer.invoke(
366+
'uc:presence-heartbeat',
367+
{ baseUrl, appVersion, currentAppid: opts?.currentAppid, currentGameName: opts?.currentGameName }
368+
),
369+
onChanged: (handler) => {
370+
if (typeof handler !== 'function') return () => {}
371+
const listener = (_event, detail) => handler(detail || {})
372+
ipcRenderer.on('uc:presence-changed', listener)
373+
return () => ipcRenderer.removeListener('uc:presence-changed', listener)
374+
},
363375
})
364376

365377
// Storage reservation API (pre-download space checks)

electron/system-profile.cjs

Lines changed: 236 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,48 @@ async function probeLinuxGpus() {
776776
if (m) glRenderer = m[1].trim()
777777
}
778778

779+
// 3a. vulkaninfo deviceName — Mesa/RADV/anvil resolve marketing names
780+
// from the binary driver even when pci.ids is stale. Collect ALL discrete
781+
// GPUs reported (so a system with multiple cards still gets per-card data).
782+
const vkNamesByPci = new Map()
783+
const vkNames = []
784+
const vkInfo = await runCommand('vulkaninfo', ['--summary'], { timeoutMs: 4000 })
785+
if (vkInfo.ok) {
786+
// --summary blocks look like:
787+
// GPU0:
788+
// deviceName = AMD Radeon RX 6700 XT (RADV NAVI22)
789+
// deviceID = 0x73df
790+
// vendorID = 0x1002
791+
const blocks = vkInfo.stdout.split(/\bGPU\d+:/i)
792+
for (const block of blocks) {
793+
const nameMatch = block.match(/deviceName\s*=\s*([^\n]+)/i)
794+
const vendMatch = block.match(/vendorID\s*=\s*0x([0-9a-f]+)/i)
795+
const devMatch = block.match(/deviceID\s*=\s*0x([0-9a-f]+)/i)
796+
if (!nameMatch) continue
797+
const name = nameMatch[1].trim()
798+
const vendorId = vendMatch ? vendMatch[1].toLowerCase().padStart(4, '0') : null
799+
const deviceId = devMatch ? devMatch[1].toLowerCase().padStart(4, '0') : null
800+
if (vendorId && deviceId) vkNamesByPci.set(`${vendorId}:${deviceId}`, name)
801+
vkNames.push({ name, vendorId, deviceId })
802+
}
803+
}
804+
805+
// 3b. lshw -C display -short — needs root for full output but the short
806+
// form often works without it and gives clean marketing names from kernel
807+
// data (modalias-resolved, not from /usr/share/hwdata).
808+
const lshw = await runCommand('lshw', ['-C', 'display', '-short'], { timeoutMs: 4000 })
809+
const lshwNames = []
810+
if (lshw.ok) {
811+
for (const line of lshw.stdout.split('\n')) {
812+
// Columns: H/W path Device Class Description
813+
const m = line.match(/\s+display\s+(.+)$/i)
814+
if (!m) continue
815+
const desc = m[1].trim()
816+
if (!desc || /^display$/i.test(desc)) continue
817+
lshwNames.push(desc)
818+
}
819+
}
820+
779821
// 4. nvidia-smi for NVIDIA cards (gives proper marketing name + VRAM +
780822
// driver version even when pci.ids is stale).
781823
const nvsmi = await runCommand('nvidia-smi', ['--query-gpu=index,name,memory.total,driver_version,pci.bus_id', '--format=csv,noheader,nounits'], { timeoutMs: 3000 })
@@ -810,15 +852,41 @@ async function probeLinuxGpus() {
810852
}
811853
}
812854

813-
// Fall back to glxinfo renderer when we still have placeholder names.
855+
// Fall back across all available enrichment sources so we never ship a
856+
// raw "[8086:1111]" / placeholder string in the UI.
814857
for (const g of gpus) {
858+
// (a) vulkaninfo matched by PCI id is the strongest signal.
859+
if (!g.name && g.vendorId && g.deviceId) {
860+
const vk = vkNamesByPci.get(`${g.vendorId}:${g.deviceId}`)
861+
if (vk) g.name = vk
862+
}
863+
// (b) lshw -short marketing name for the matching vendor.
864+
if (!g.name && lshwNames.length > 0) {
865+
const matched = lshwNames.find((n) => detectGpuVendor(n) === g.vendor)
866+
if (matched) g.name = matched
867+
}
868+
// (c) glxinfo renderer (only one entry, so first GPU of matching vendor).
815869
if (!g.name && glRenderer && (g.vendor === detectGpuVendor(glRenderer) || g.vendor === 'unknown')) {
816870
g.name = glRenderer.replace(/\s*\(.*?\)\s*$/, '').trim() || glRenderer
817871
}
818-
// Last-resort label so the UI never has to print "Device 1111".
819-
if (!g.name) {
820-
if (g.vendorId && g.deviceId) g.name = `${g.vendorName || g.vendor || 'Unknown vendor'} [${g.vendorId}:${g.deviceId}]`
821-
else if (g.vendorName) g.name = `${g.vendorName} GPU`
872+
// (d) Any unmatched vulkaninfo entry of the right vendor.
873+
if (!g.name && vkNames.length > 0) {
874+
const matched = vkNames.find((v) => detectGpuVendor(v.name) === g.vendor)
875+
if (matched) g.name = matched.name
876+
}
877+
}
878+
879+
// Last-resort: render a friendly fallback that doesn't look like a stale
880+
// pci.ids placeholder. We prefer "Intel integrated GPU" over "[8086:1111]".
881+
for (const g of gpus) {
882+
if (g.name) continue
883+
if (g.vendor && g.vendor !== 'unknown') {
884+
const friendly = { nvidia: 'NVIDIA', amd: 'AMD', intel: 'Intel', apple: 'Apple' }[g.vendor] || g.vendor
885+
g.name = `${friendly} GPU`
886+
} else if (g.vendorName) {
887+
g.name = `${g.vendorName} GPU`
888+
} else if (g.vendorId) {
889+
g.name = `GPU [vendor ${g.vendorId}]`
822890
}
823891
}
824892

@@ -927,21 +995,175 @@ function probeLinuxVolumes() {
927995
return out
928996
}
929997

998+
// PNP-ID → manufacturer name. We ship the most common subset so EDID strings
999+
// like "DEL" / "SAM" resolve to "Dell" / "Samsung" without a 5k-line table.
1000+
const EDID_PNP_VENDORS = {
1001+
AAA: 'Avolites', ACI: 'Asus', ACR: 'Acer', AOC: 'AOC', APP: 'Apple', AUS: 'Asus',
1002+
BNQ: 'BenQ', CMN: 'Chimei Innolux', CMO: 'Chi Mei Optoelectronics',
1003+
DEL: 'Dell', GBT: 'Gigabyte', GSM: 'LG', HPN: 'HP', HSD: 'HannStar',
1004+
HWP: 'HP', IVM: 'Iiyama', LEN: 'Lenovo', LGD: 'LG Display', LGE: 'LG',
1005+
MEI: 'Panasonic', MSI: 'MSI', NEC: 'NEC', PHL: 'Philips', PNP: 'Plug & Play',
1006+
QDS: 'Quanta', SAM: 'Samsung', SDC: 'Samsung Display', SEC: 'Samsung',
1007+
SHP: 'Sharp', SNY: 'Sony', VIZ: 'Vizio', VSC: 'ViewSonic',
1008+
}
1009+
1010+
function decodeEdidPnpId(byte1, byte2) {
1011+
// EDID compresses 3 letters into 16 bits, with '@' (0x40) as the base.
1012+
const b1 = byte1 || 0
1013+
const b2 = byte2 || 0
1014+
const c1 = ((b1 >> 2) & 0x1f) + 64
1015+
const c2 = (((b1 & 0x3) << 3) | ((b2 >> 5) & 0x7)) + 64
1016+
const c3 = (b2 & 0x1f) + 64
1017+
if (c1 < 65 || c1 > 90 || c2 < 65 || c2 > 90 || c3 < 65 || c3 > 90) return null
1018+
return String.fromCharCode(c1) + String.fromCharCode(c2) + String.fromCharCode(c3)
1019+
}
1020+
1021+
function parseEdidBuffer(buf) {
1022+
if (!buf || buf.length < 128) return null
1023+
// EDID header: 00 FF FF FF FF FF FF 00
1024+
if (buf[0] !== 0x00 || buf[1] !== 0xff || buf[2] !== 0xff || buf[3] !== 0xff) return null
1025+
const pnpId = decodeEdidPnpId(buf[8], buf[9])
1026+
// Detailed descriptors (bytes 54..125, four 18-byte blocks). The first
1027+
// detailed timing block is conventionally the preferred (native) mode.
1028+
let modelName = null
1029+
let nativeWidth = null
1030+
let nativeHeight = null
1031+
for (let i = 54; i <= 108; i += 18) {
1032+
const block = buf.slice(i, i + 18)
1033+
if (block.length < 18) continue
1034+
if (block[0] === 0 && block[1] === 0) {
1035+
// Descriptor type byte at offset 3 within the block.
1036+
const type = block[3]
1037+
if (type === 0xfc) {
1038+
// Monitor name: ASCII, terminated by 0x0a or end-of-block.
1039+
const raw = block.slice(5, 18)
1040+
let text = ''
1041+
for (const b of raw) {
1042+
if (b === 0x0a) break
1043+
text += String.fromCharCode(b)
1044+
}
1045+
if (text.trim()) modelName = text.trim()
1046+
}
1047+
} else if (nativeWidth == null && nativeHeight == null) {
1048+
// First non-zero detailed timing descriptor → preferred timing.
1049+
const hAct = block[2] | ((block[4] & 0xf0) << 4)
1050+
const vAct = block[5] | ((block[7] & 0xf0) << 4)
1051+
if (hAct && vAct) {
1052+
nativeWidth = hAct
1053+
nativeHeight = vAct
1054+
}
1055+
}
1056+
}
1057+
return { pnpId, modelName, nativeWidth, nativeHeight }
1058+
}
1059+
9301060
async function probeLinuxDisplays() {
931-
const xrandr = await runCommand('xrandr', ['--current'], { timeoutMs: 2000 })
932-
if (!xrandr.ok) return []
9331061
const displays = []
934-
for (const line of xrandr.stdout.split('\n')) {
935-
const m = line.match(/^\s*(\d+)x(\d+)\s+([\d.]+)\*/)
936-
if (m) {
1062+
const seen = new Set()
1063+
1064+
// 1. Per-connector EDID under /sys/class/drm covers every connected
1065+
// monitor — not just the one xrandr happens to mark as the active mode.
1066+
try {
1067+
for (const entry of fs.readdirSync('/sys/class/drm')) {
1068+
// card0-HDMI-A-1, card0-DP-1, card0-eDP-1, …
1069+
if (!/^card\d+-/.test(entry)) continue
1070+
const connectorDir = `/sys/class/drm/${entry}`
1071+
const statusRaw = readFileSafe(`${connectorDir}/status`)
1072+
const status = (statusRaw || '').trim()
1073+
if (status !== 'connected') continue
1074+
const connectorName = entry.replace(/^card\d+-/, '')
1075+
1076+
// EDID is a binary file; readFileSafe returns latin-1 text which keeps
1077+
// bytes intact for indexing. Re-read it as a Buffer for accuracy.
1078+
let edid = null
1079+
try {
1080+
const buf = fs.readFileSync(`${connectorDir}/edid`)
1081+
if (buf && buf.length >= 128) edid = parseEdidBuffer(buf)
1082+
} catch { /* edid often empty for ghost connectors — skip */ }
1083+
1084+
const manufacturer = edid?.pnpId ? (EDID_PNP_VENDORS[edid.pnpId] || edid.pnpId) : null
1085+
const label = edid?.modelName || (manufacturer ? `${manufacturer} (${connectorName})` : connectorName)
1086+
const key = `${connectorName}|${edid?.modelName || ''}`
1087+
if (seen.has(key)) continue
1088+
seen.add(key)
9371089
displays.push({
938-
label: null,
939-
width: Number(m[1]),
940-
height: Number(m[2]),
941-
refreshHz: Math.round(Number(m[3])),
1090+
label,
1091+
connector: connectorName,
1092+
manufacturer,
1093+
model: edid?.modelName || null,
1094+
nativeWidth: edid?.nativeWidth || null,
1095+
nativeHeight: edid?.nativeHeight || null,
1096+
// Width / height / refresh below are the *current* mode; populated
1097+
// from xrandr in step 2.
1098+
width: edid?.nativeWidth || null,
1099+
height: edid?.nativeHeight || null,
1100+
refreshHz: null,
9421101
})
9431102
}
1103+
} catch { /* /sys/class/drm absent — fall through to xrandr-only path */ }
1104+
1105+
// 2. xrandr --current — gives the active mode + refresh for each connected
1106+
// output. Match by connector name when we already have the row from EDID,
1107+
// otherwise add a fresh entry (e.g. running under Wayland with no /sys
1108+
// access, or a connector we couldn't EDID-read).
1109+
const xrandr = await runCommand('xrandr', ['--current'], { timeoutMs: 2000 })
1110+
if (xrandr.ok) {
1111+
let currentConnector = null
1112+
for (const line of xrandr.stdout.split('\n')) {
1113+
const conMatch = line.match(/^(\S+)\s+connected\b/)
1114+
if (conMatch) {
1115+
currentConnector = conMatch[1]
1116+
if (!displays.some((d) => d.connector === currentConnector)) {
1117+
displays.push({
1118+
label: currentConnector,
1119+
connector: currentConnector,
1120+
manufacturer: null,
1121+
model: null,
1122+
nativeWidth: null,
1123+
nativeHeight: null,
1124+
width: null,
1125+
height: null,
1126+
refreshHz: null,
1127+
})
1128+
}
1129+
continue
1130+
}
1131+
const modeMatch = line.match(/^\s*(\d+)x(\d+)\s+([\d.]+)\*/)
1132+
if (modeMatch && currentConnector) {
1133+
const target = displays.find((d) => d.connector === currentConnector)
1134+
const width = Number(modeMatch[1])
1135+
const height = Number(modeMatch[2])
1136+
const refresh = Math.round(Number(modeMatch[3]))
1137+
if (target) {
1138+
target.width = width
1139+
target.height = height
1140+
target.refreshHz = refresh
1141+
}
1142+
}
1143+
}
1144+
}
1145+
1146+
// Fall back to a single legacy-shaped entry if both probes failed (very
1147+
// restricted sandboxes) so the panel doesn't render as "0 monitors".
1148+
if (displays.length === 0 && xrandr.ok) {
1149+
for (const line of xrandr.stdout.split('\n')) {
1150+
const m = line.match(/^\s*(\d+)x(\d+)\s+([\d.]+)\*/)
1151+
if (m) {
1152+
displays.push({
1153+
label: null,
1154+
connector: null,
1155+
manufacturer: null,
1156+
model: null,
1157+
nativeWidth: null,
1158+
nativeHeight: null,
1159+
width: Number(m[1]),
1160+
height: Number(m[2]),
1161+
refreshHz: Math.round(Number(m[3])),
1162+
})
1163+
}
1164+
}
9441165
}
1166+
9451167
return displays
9461168
}
9471169

0 commit comments

Comments
 (0)