@@ -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 ( / \b G P U \d + : / i)
792+ for ( const block of blocks ) {
793+ const nameMatch = block . match ( / d e v i c e N a m e \s * = \s * ( [ ^ \n ] + ) / i)
794+ const vendMatch = block . match ( / v e n d o r I D \s * = \s * 0 x ( [ 0 - 9 a - f ] + ) / i)
795+ const devMatch = block . match ( / d e v i c e I D \s * = \s * 0 x ( [ 0 - 9 a - 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 + d i s p l a y \s + ( .+ ) $ / i)
814+ if ( ! m ) continue
815+ const desc = m [ 1 ] . trim ( )
816+ if ( ! desc || / ^ d i s p l a y $ / 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+
9301060async 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 ( ! / ^ c a r d \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 ( / ^ c a r d \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 + c o n n e c t e d \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