Skip to content

Commit ff17ee7

Browse files
committed
Unreleased - 2026-05-19
1 parent c1e3048 commit ff17ee7

6 files changed

Lines changed: 232 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased - 2026-05-19
4+
5+
### Wishlist
6+
7+
- **PC compatibility card on the Wishlist page** — the upgrade-suggestions card from Settings → System Profile is now surfaced directly on the Wishlist page. When all wishlisted games pass minimum requirements it shows the "Your PC clears every wishlist game" good-news banner; otherwise it shows the bottleneck breakdown with per-component suggestions. The card is skipped entirely when no system profile has been scanned or the wishlist has no evaluable games.
8+
39
## v2.2.0 — Crossroads · 2026-05-19
410

511
Linux gets first-class treatment across the launcher, the system scanner gets sharper teeth, and the updater + installer stop tripping over themselves. Picks up the system-profile groundwork laid in v2.1.0 and makes that data actually matter cross-platform — sysreq panels auto-pick your OS, the runnable filter respects it, the scanner reports DDR/NVMe types correctly on both platforms, and the launcher no longer pretends a Linux user lives in a Windows world.

electron/main.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,7 +1761,10 @@ function applySettingsDefaults(settings) {
17611761
forums: 'off',
17621762
profilePublic: 'off',
17631763
sysreqCheck: 'on', // local pre-download check — no PII leaves device
1764+
shareGamePlaytime: true, // when false, user is excluded from playtime leaderboard + public playtime card
17641765
}
1766+
} else if (typeof next.systemProfileVisibility.shareGamePlaytime !== 'boolean') {
1767+
next.systemProfileVisibility.shareGamePlaytime = true
17651768
}
17661769
if (typeof next.systemProfileSyncEnabled !== 'boolean') next.systemProfileSyncEnabled = false
17671770
// When sharing error logs / diagnostics, optionally include a one-line hardware

electron/system-profile.cjs

Lines changed: 188 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const path = require('node:path')
1212
const crypto = require('node:crypto')
1313
const child_process = require('node:child_process')
1414

15-
const SPEC_VERSION = 2
15+
const SPEC_VERSION = 3
1616

1717
// PowerShell on Windows has a meaningful cold-start cost (CLR init + WMI
1818
// service spin-up can easily blow past 5s on the first call after boot),
@@ -139,8 +139,101 @@ try {
139139
}
140140
} catch { }
141141
142+
# Real VRAM via the display-adapter class key. Win32_VideoController.AdapterRAM
143+
# is a DWORD and is capped at 4GB (and often outright wrong on modern cards);
144+
# the kernel writes the 64-bit truth into HardwareInformation.qwMemorySize.
145+
# We collect every adapter subkey and let the JS side correlate by PNPDeviceID
146+
# (MatchingDeviceId) so we can attach VRAM to the right Win32_VideoController row.
147+
$adapters = @()
148+
try {
149+
$classRoot = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}'
150+
if (Test-Path $classRoot) {
151+
$adapters = Get-ChildItem $classRoot -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match '^\d{4}$' } | ForEach-Object {
152+
$vals = $null
153+
try { $vals = Get-ItemProperty -Path $_.PSPath -ErrorAction Stop } catch { }
154+
if ($vals) {
155+
@{
156+
subkey = $_.PSChildName
157+
driverDesc = "$($vals.DriverDesc)"
158+
matchingDevId = "$($vals.MatchingDeviceId)"
159+
# qwMemorySize is the 64-bit value; HardwareInformation.MemorySize is the
160+
# legacy DWORD. Prefer the QWORD when present.
161+
qwMemorySize = if ($vals.'HardwareInformation.qwMemorySize') { [int64]$vals.'HardwareInformation.qwMemorySize' } else { $null }
162+
dwMemorySize = if ($vals.'HardwareInformation.MemorySize') { [int64]$vals.'HardwareInformation.MemorySize' } else { $null }
163+
}
164+
}
165+
}
166+
}
167+
} catch { }
168+
169+
# Physical monitors (panels actually attached) via the WMI monitor namespace.
170+
# Win32_VideoController only knows about adapters; a single GPU can drive 1..N
171+
# monitors. We pull EDID-derived manufacturer/product strings + the largest
172+
# supported source mode (proxy for the panel's native resolution) so the
173+
# scanner reports every attached monitor, not just "the GPU's primary".
174+
$monitors = @()
175+
try {
176+
$ids = Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorID -ErrorAction Stop
177+
$modes = @()
178+
try { $modes = Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorListedSupportedSourceModes -ErrorAction Stop } catch { }
179+
$modesByInstance = @{}
180+
foreach ($m in $modes) {
181+
$modesByInstance[$m.InstanceName] = $m
182+
}
183+
foreach ($id in $ids) {
184+
$name = ''
185+
if ($id.UserFriendlyName) {
186+
$name = -join ($id.UserFriendlyName | Where-Object { $_ -gt 0 } | ForEach-Object { [char]$_ })
187+
}
188+
$manuf = ''
189+
if ($id.ManufacturerName) {
190+
$manuf = -join ($id.ManufacturerName | Where-Object { $_ -gt 0 } | ForEach-Object { [char]$_ })
191+
}
192+
$product = ''
193+
if ($id.ProductCodeID) {
194+
$product = -join ($id.ProductCodeID | Where-Object { $_ -gt 0 } | ForEach-Object { [char]$_ })
195+
}
196+
$serial = ''
197+
if ($id.SerialNumberID) {
198+
$serial = -join ($id.SerialNumberID | Where-Object { $_ -gt 0 } | ForEach-Object { [char]$_ })
199+
}
200+
# Native mode = largest preferred mode. The list is unordered; pick the one
201+
# with the highest horizontal active pixels and matching vertical.
202+
$w = $null; $h = $null; $hz = $null
203+
$sm = $modesByInstance[$id.InstanceName]
204+
if ($sm -and $sm.MonitorSourceModes) {
205+
$best = $sm.MonitorSourceModes | Sort-Object -Property HorizontalActivePixels -Descending | Select-Object -First 1
206+
if ($best) {
207+
$w = [int]$best.HorizontalActivePixels
208+
$h = [int]$best.VerticalActivePixels
209+
if ($best.VerticalRefreshRateNumerator -and $best.VerticalRefreshRateDenominator) {
210+
$hz = [int][math]::Round($best.VerticalRefreshRateNumerator / $best.VerticalRefreshRateDenominator)
211+
}
212+
}
213+
}
214+
$monitors += @{
215+
instance = $id.InstanceName
216+
name = $name
217+
manuf = $manuf
218+
product = $product
219+
serial = $serial
220+
yearOfManufacture = $id.YearOfManufacture
221+
active = [bool]$id.Active
222+
width = $w
223+
height = $h
224+
refreshHz = $hz
225+
}
226+
}
227+
} catch { }
228+
229+
# DPI / current-mode pass: Win32_DesktopMonitor has the *current* (not native)
230+
# resolution for whichever monitor each Win32_VideoController is currently
231+
# driving. Useful as a fallback when WmiMonitor* isn't accessible (some
232+
# locked-down corporate images).
233+
$desktopMonitors = Try-CIM 'Win32_DesktopMonitor' $null | Select-Object Name,ScreenWidth,ScreenHeight,DeviceID,PNPDeviceID,MonitorManufacturer,MonitorType
234+
142235
$out = @{
143-
cs=$cs; os=$os_; cpu=$cpu; gpu=$gpu; mem=$mem; disk=$disk; vol=$vol; volMedia=$volMedia; pdisks=$pdisks
236+
cs=$cs; os=$os_; cpu=$cpu; gpu=$gpu; mem=$mem; disk=$disk; vol=$vol; volMedia=$volMedia; pdisks=$pdisks; adapters=$adapters; monitors=$monitors; desktopMonitors=$desktopMonitors
144237
}
145238
$out | ConvertTo-Json -Depth 8 -Compress
146239
`
@@ -172,14 +265,39 @@ async function scanWindows() {
172265
const cpu = cpus[0] || {}
173266
const archMap = { 0: 'x86', 5: 'arm', 6: 'ia64', 9: 'x64', 12: 'arm64' }
174267

268+
// Build a lookup of "real" VRAM from the registry. AdapterRAM (DWORD) is
269+
// capped at 4GB and outright wrong on most modern cards. The display class
270+
// key stores the 64-bit HardwareInformation.qwMemorySize that we trust over
271+
// the WMI value. Correlate by PNPDeviceID prefix.
272+
const vramByPnpPrefix = new Map()
273+
for (const a of toArray(parsed.adapters)) {
274+
const matching = String(a?.matchingDevId || '').toUpperCase()
275+
if (!matching) continue
276+
const bytes = Number(a?.qwMemorySize) || Number(a?.dwMemorySize) || 0
277+
if (!bytes) continue
278+
// Strip the subsys/rev suffix so we match "PCI\VEN_10DE&DEV_2786" against
279+
// both shortened PnP IDs and full ones.
280+
const key = matching.split('&').slice(0, 2).join('&')
281+
vramByPnpPrefix.set(key, bytes)
282+
vramByPnpPrefix.set(matching, bytes)
283+
}
284+
function lookupRealVram(pnp) {
285+
if (!pnp) return null
286+
const up = String(pnp).toUpperCase()
287+
if (vramByPnpPrefix.has(up)) return vramByPnpPrefix.get(up)
288+
const short = up.split('&').slice(0, 2).join('&')
289+
if (vramByPnpPrefix.has(short)) return vramByPnpPrefix.get(short)
290+
return null
291+
}
292+
175293
// Rank GPUs so virtual / paravirtual adapters (Meta Oculus Virtual,
176294
// Parsec, Microsoft Basic Display, IddSampleDriver, RDP, Hyper-V) lose
177295
// to real silicon when the renderer reads `gpus[0]`.
178296
const rawGpus = toArray(parsed.gpu)
179297
.filter((g) => g && (g.Name || g.PNPDeviceID))
180298
.map((g) => ({
181299
name: g?.Name || null,
182-
vramBytes: Number(g?.AdapterRAM) || null,
300+
vramBytes: lookupRealVram(g?.PNPDeviceID) || Number(g?.AdapterRAM) || null,
183301
vendor: detectGpuVendor(g?.Name || ''),
184302
driverVersion: g?.DriverVersion || null,
185303
driverDate: g?.DriverDate || null,
@@ -258,23 +376,77 @@ async function scanWindows() {
258376
}
259377
})
260378

261-
// Re-derive displays from the same Win32_VideoController rows (they
262-
// carry CurrentHorizontalResolution/Refresh). Filter to active adapters
263-
// so a disabled Oculus virtual display doesn't show up as your monitor.
264-
const displays = toArray(parsed.gpu)
265-
.filter((d) => d?.CurrentHorizontalResolution && d?.CurrentVerticalResolution)
266-
.map((d) => ({
267-
label: d?.Name || null,
268-
width: Number(d.CurrentHorizontalResolution) || null,
269-
height: Number(d.CurrentVerticalResolution) || null,
270-
refreshHz: Number(d.CurrentRefreshRate) || null,
271-
}))
272-
// Same virtual-adapter pushdown as GPUs.
379+
// Enumerate every *physical* monitor attached to the machine. The previous
380+
// implementation derived displays from Win32_VideoController, which is
381+
// per-adapter and only ever reported one entry (the GPU's primary surface)
382+
// — multi-monitor setups appeared as a single 1080p panel. Now we use
383+
// root/wmi → WmiMonitorID for the panel list and WmiMonitorListedSupported-
384+
// SourceModes for the native resolution, falling back to Win32_DesktopMonitor
385+
// and finally Win32_VideoController for locked-down systems where the WMI
386+
// monitor namespace is blocked.
387+
const monitorRows = toArray(parsed.monitors).filter((m) => m && (m.name || m.product || m.manuf))
388+
let displays = monitorRows.map((m) => ({
389+
label: (m.name && m.name.trim()) || [m.manuf, m.product].filter(Boolean).join(' ').trim() || null,
390+
width: Number(m.width) || null,
391+
height: Number(m.height) || null,
392+
refreshHz: Number(m.refreshHz) || null,
393+
manufacturer: m.manuf || null,
394+
product: m.product || null,
395+
serial: m.serial || null,
396+
active: m.active !== false,
397+
}))
398+
399+
if (displays.length === 0) {
400+
// Win32_DesktopMonitor fallback. Multiple rows per machine on multi-head
401+
// rigs; sometimes one ghost row with zero dimensions — filter those.
402+
displays = toArray(parsed.desktopMonitors)
403+
.filter((d) => Number(d?.ScreenWidth) && Number(d?.ScreenHeight))
404+
.map((d) => ({
405+
label: d?.Name || d?.MonitorType || null,
406+
width: Number(d.ScreenWidth) || null,
407+
height: Number(d.ScreenHeight) || null,
408+
refreshHz: null,
409+
manufacturer: d?.MonitorManufacturer || null,
410+
product: null,
411+
serial: null,
412+
active: true,
413+
}))
414+
}
415+
416+
if (displays.length === 0) {
417+
// Last-resort fallback: the old per-adapter view. Only fires when both
418+
// monitor namespaces are unavailable (rare).
419+
displays = toArray(parsed.gpu)
420+
.filter((d) => d?.CurrentHorizontalResolution && d?.CurrentVerticalResolution)
421+
.map((d) => ({
422+
label: d?.Name || null,
423+
width: Number(d.CurrentHorizontalResolution) || null,
424+
height: Number(d.CurrentVerticalResolution) || null,
425+
refreshHz: Number(d.CurrentRefreshRate) || null,
426+
manufacturer: null,
427+
product: null,
428+
serial: null,
429+
active: true,
430+
}))
431+
}
432+
433+
// Push virtual / inactive monitors to the back, and dedupe entries that
434+
// share width+height+label (Win32_DesktopMonitor sometimes lists the same
435+
// monitor twice once via DDC and once via EDID).
436+
const seen = new Set()
437+
displays = displays
273438
.sort((a, b) => {
439+
if (a.active !== b.active) return a.active ? -1 : 1
274440
const av = isVirtualGpu(a.label || '', '')
275441
const bv = isVirtualGpu(b.label || '', '')
276442
if (av !== bv) return av ? 1 : -1
277-
return 0
443+
return (b.width || 0) - (a.width || 0)
444+
})
445+
.filter((d) => {
446+
const k = `${d.label}|${d.width}|${d.height}|${d.serial || ''}`
447+
if (seen.has(k)) return false
448+
seen.add(k)
449+
return true
278450
})
279451

280452
return {

renderer/src/app/pages/WishlistPage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from "react"
1+
import { useCallback, useEffect, useMemo, useState } from "react"
22
import { useNavigate } from "react-router-dom"
33
import { Button } from "@/components/ui/button"
44
import { Card, CardContent } from "@/components/ui/card"
@@ -7,6 +7,7 @@ import { GameCardSkeleton } from "@/components/GameCardSkeleton"
77
import { apiFetch, apiUrl, getApiBaseUrl } from "@/lib/api"
88
import { useDiscordAccount } from "@/hooks/use-discord-account"
99
import { Heart, LogIn, RefreshCw, Star } from "lucide-react"
10+
import { UpgradeSuggesterSection } from "@/components/SystemProfilePanel"
1011

1112
interface Game {
1213
appid: string
@@ -32,6 +33,8 @@ export function WishlistPage() {
3233
const [loggingIn, setLoggingIn] = useState(false)
3334
const [refreshing, setRefreshing] = useState(false)
3435

36+
const baseUrl = useMemo(() => { try { return getApiBaseUrl() } catch { return undefined } }, [])
37+
3538
const loadItems = useCallback(async (retrySession = true) => {
3639
setError(null)
3740
setLoading(true)
@@ -123,6 +126,10 @@ export function WishlistPage() {
123126
</div>
124127
)}
125128

129+
{accountUser && (
130+
<UpgradeSuggesterSection baseUrl={baseUrl} />
131+
)}
132+
126133
{!accountUser && !accountLoading ? null : loading || accountLoading ? (
127134
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
128135
{Array.from({ length: 10 }).map((_, idx) => (

renderer/src/components/SystemProfilePanel.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { Card, CardContent } from "@/components/ui/card"
33
import { Button } from "@/components/ui/button"
44
import { Badge } from "@/components/ui/badge"
55
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
6-
import { Cpu, Loader2, RefreshCw, Monitor, HardDrive, Zap, MemoryStick, Trash2, ShieldCheck, CloudUpload, CloudOff, CheckCircle2, Laptop, X, Check, Pencil, TrendingUp } from "lucide-react"
6+
import { Cpu, Loader2, RefreshCw, Monitor, HardDrive, Zap, MemoryStick, Trash2, ShieldCheck, CloudUpload, CloudOff, CheckCircle2, Laptop, X, Check, Pencil, TrendingUp, Clock3 } from "lucide-react"
7+
import { Switch } from "@/components/ui/switch"
78
import { Input } from "@/components/ui/input"
89
import { getApiBaseUrl } from "@/lib/api"
910

@@ -12,6 +13,7 @@ const DEFAULT_VISIBILITY: SystemProfileVisibility = {
1213
forums: "off",
1314
profilePublic: "off",
1415
sysreqCheck: "on",
16+
shareGamePlaytime: true,
1517
}
1618

1719
function formatBytes(bytes: number | null | undefined): string {
@@ -207,6 +209,7 @@ export function SystemProfilePanel({ autoScanOnMount = false, onAutoScanConsumed
207209
comments: next.comments === "summary" ? "summary" : "off",
208210
forums: next.forums === "summary" ? "summary" : "off",
209211
profilePublic: next.profilePublic,
212+
shareGamePlaytime: next.shareGamePlaytime,
210213
})
211214
} catch { /* swallow, will retry on next scan/visibility change */ }
212215
}
@@ -350,6 +353,22 @@ export function SystemProfilePanel({ autoScanOnMount = false, onAutoScanConsumed
350353
value={visibility.profilePublic}
351354
onChange={(v) => updateVisibility({ profilePublic: v })}
352355
/>
356+
<div className="flex items-start justify-between gap-4 py-2">
357+
<div className="flex-1 min-w-0">
358+
<div className="text-sm font-medium flex items-center gap-1.5">
359+
<Clock3 className="h-3.5 w-3.5 text-zinc-400" />
360+
Game playtime sharing
361+
</div>
362+
<div className="text-xs text-zinc-400 mt-0.5">
363+
When on, your playtime appears on the public leaderboard and the playtime card on your profile (with which games you've played). <b>When off, you're removed from the playtime leaderboard and the card is hidden</b> — but your sessions are still tracked, so flipping this back on later restores everything without losing any playtime.
364+
</div>
365+
</div>
366+
<Switch
367+
checked={visibility.shareGamePlaytime}
368+
onCheckedChange={(v) => updateVisibility({ shareGamePlaytime: v })}
369+
aria-label="Share game playtime publicly"
370+
/>
371+
</div>
353372
<div className="flex items-start justify-between gap-4 py-2">
354373
<div className="flex-1 min-w-0">
355374
<div className="text-sm font-medium">Pre-download requirement check</div>
@@ -397,7 +416,7 @@ type UpgradeReport = {
397416
}>
398417
}
399418

400-
function UpgradeSuggesterSection({ baseUrl }: { baseUrl: string | undefined }) {
419+
export function UpgradeSuggesterSection({ baseUrl }: { baseUrl: string | undefined }) {
401420
const [report, setReport] = useState<UpgradeReport | null>(null)
402421
const [reason, setReason] = useState<string | null>(null)
403422
const [loading, setLoading] = useState(true)
@@ -418,6 +437,8 @@ function UpgradeSuggesterSection({ baseUrl }: { baseUrl: string | undefined }) {
418437
setReason(res.reason || res.error || null)
419438
}
420439
} catch (err: any) {
440+
// ignore
441+
} finally {
421442
setLoading(false)
422443
}
423444
})()

renderer/src/vite-env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ declare global {
131131
forums: SystemProfileVisibilityTier
132132
profilePublic: SystemProfileVisibilityTier
133133
sysreqCheck: 'off' | 'on'
134+
/** When false, user is excluded from the playtime leaderboard and the
135+
* playtime card on their public profile. Sessions still record locally
136+
* and on the server, so flipping back on restores everything. */
137+
shareGamePlaytime: boolean
134138
}
135139

136140
/** Pre-download storage reservation check result from window.ucStorage.precheck. */

0 commit comments

Comments
 (0)