From ea0a2d8aae2d9655c33b9a77773218afec5ec964 Mon Sep 17 00:00:00 2001 From: hampus-fluxer Date: Tue, 6 Jan 2026 01:15:21 +0100 Subject: [PATCH] fix(app): improve macos version and arch detection (#36) --- fluxer_app/src-electron/common/types.ts | 2 + fluxer_app/src-electron/main/ipc-handlers.ts | 36 ++++ .../modals/components/ClientInfo.tsx | 42 ++++- fluxer_app/src/utils/ClientInfoUtils.ts | 173 +++++++++++++++++- 4 files changed, 236 insertions(+), 17 deletions(-) diff --git a/fluxer_app/src-electron/common/types.ts b/fluxer_app/src-electron/common/types.ts index e4ae6c05..0473503e 100644 --- a/fluxer_app/src-electron/common/types.ts +++ b/fluxer_app/src-electron/common/types.ts @@ -28,6 +28,8 @@ export interface DesktopInfo { version: string; channel: 'stable' | 'canary'; arch: string; + hardwareArch: string; + runningUnderRosetta: boolean; os: NodeJS.Platform; osVersion: string; } diff --git a/fluxer_app/src-electron/main/ipc-handlers.ts b/fluxer_app/src-electron/main/ipc-handlers.ts index fbd47fad..9a4acd73 100644 --- a/fluxer_app/src-electron/main/ipc-handlers.ts +++ b/fluxer_app/src-electron/main/ipc-handlers.ts @@ -17,6 +17,7 @@ * along with Fluxer. If not, see . */ +import child_process from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -193,6 +194,39 @@ const ensureRpId = (value: string | undefined, context: string): string => { return value; }; +const runSysctl = (query: string): string | null => { + try { + return child_process + .execSync(query, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim(); + } catch { + return null; + } +}; + +const detectRosettaMode = (): boolean => { + if (process.platform !== 'darwin') { + return false; + } + + const translated = runSysctl('sysctl -n sysctl.proc_translated'); + return translated === '1'; +}; + +const detectHardwareArch = (): string => { + if (process.platform !== 'darwin') { + return os.arch(); + } + const optionalArm64 = runSysctl('sysctl -n hw.optional.arm64'); + if (optionalArm64 === '1') { + return 'arm64'; + } + return os.arch(); +}; + const convertMacCreationOptions = (options: PublicKeyCredentialCreationOptionsJSON): CreateCredentialOptions => ({ rpId: ensureRpId(options.rp.id, 'registration'), userId: base64UrlToBuffer(options.user.id).toString('base64'), @@ -374,6 +408,8 @@ export function registerIpcHandlers(): void { version: app.getVersion(), channel: BUILD_CHANNEL, arch: process.arch, + hardwareArch: detectHardwareArch(), + runningUnderRosetta: detectRosettaMode(), os: process.platform, osVersion: os.release(), }), diff --git a/fluxer_app/src/components/modals/components/ClientInfo.tsx b/fluxer_app/src/components/modals/components/ClientInfo.tsx index 66eae0a2..cda036d9 100644 --- a/fluxer_app/src/components/modals/components/ClientInfo.tsx +++ b/fluxer_app/src/components/modals/components/ClientInfo.tsx @@ -19,19 +19,32 @@ import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; +import {useEffect, useState} from 'react'; import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators'; import * as ToastActionCreators from '~/actions/ToastActionCreators'; import Config from '~/Config'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import DeveloperModeStore from '~/stores/DeveloperModeStore'; -import {getClientInfoSync} from '~/utils/ClientInfoUtils'; +import {getClientInfo, getClientInfoSync} from '~/utils/ClientInfoUtils'; import * as DateUtils from '~/utils/DateUtils'; +import {isDesktop} from '~/utils/NativeUtils'; import styles from './ClientInfo.module.css'; export const ClientInfo = observer(() => { const {t, i18n} = useLingui(); - const clientInfo = getClientInfoSync(); + const [clientInfo, setClientInfo] = useState(getClientInfoSync()); + + useEffect(() => { + let mounted = true; + void getClientInfo().then((info) => { + if (!mounted) return; + setClientInfo(info); + }); + return () => { + mounted = false; + }; + }, []); const buildShaShort = (Config.PUBLIC_BUILD_SHA ?? '').slice(0, 7); const buildNumber = Config.PUBLIC_BUILD_NUMBER; @@ -41,10 +54,21 @@ export const ClientInfo = observer(() => { const browserName = clientInfo.browserName || 'Unknown'; const browserVersion = clientInfo.browserVersion || ''; const osName = clientInfo.osName || 'Unknown'; - const osVersion = - clientInfo.osVersion && clientInfo.arch - ? `${clientInfo.osVersion} (${clientInfo.arch})` - : clientInfo.osVersion || clientInfo.arch || ''; + const rawOsVersion = clientInfo.osVersion ?? ''; + const isDesktopApp = isDesktop(); + const osArchitecture = clientInfo.desktopArch ?? clientInfo.arch; + const shouldShowOsVersion = Boolean(rawOsVersion) && (isDesktopApp || osName !== 'macOS'); + const osVersionForDisplay = shouldShowOsVersion ? rawOsVersion : undefined; + + const buildOsDescription = () => { + const parts = [osName]; + if (osVersionForDisplay) { + parts.push(osVersionForDisplay); + } + const archSuffix = osArchitecture ? ` (${osArchitecture})` : ''; + return `${parts.join(' ')}${archSuffix}`.trim(); + }; + const osDescription = buildOsDescription(); const onClick = () => { let timestamp = ''; @@ -68,7 +92,7 @@ export const ClientInfo = observer(() => { TextCopyActionCreators.copy( i18n, - `${Config.PUBLIC_PROJECT_ENV} ${buildInfo}${timestamp}, ${browserName} ${browserVersion}, ${osName} ${osVersion}${desktopInfo}`, + `${Config.PUBLIC_PROJECT_ENV} ${buildInfo}${timestamp}, ${browserName} ${browserVersion}, ${osDescription}${desktopInfo}`, ); }; @@ -92,9 +116,7 @@ export const ClientInfo = observer(() => { {browserName} {browserVersion} - - {osName} {osVersion} - + {osDescription} diff --git a/fluxer_app/src/utils/ClientInfoUtils.ts b/fluxer_app/src/utils/ClientInfoUtils.ts index 3dfbc3a9..8f805767 100644 --- a/fluxer_app/src/utils/ClientInfoUtils.ts +++ b/fluxer_app/src/utils/ClientInfoUtils.ts @@ -31,10 +31,125 @@ interface ClientInfo { desktopChannel?: string; desktopArch?: string; desktopOS?: string; + desktopRunningUnderRosetta?: boolean; } +type NavigatorHighEntropyHints = { + architecture?: string; + bitness?: string; + platform?: string; +}; + +type NavigatorUADataLike = NavigatorHighEntropyHints & { + getHighEntropyValues?: (hints: ReadonlyArray) => Promise; +}; + const normalize = (value: T | null | undefined): T | undefined => value ?? undefined; +const ARCHITECTURE_PATTERNS: ReadonlyArray<{pattern: RegExp; label: string}> = [ + {pattern: /\barm64\b|\baarch64\b|\barmv8\b|\barm64e\b/i, label: 'arm64'}, + {pattern: /\barm\b|\barmv7\b|\barmv6\b/i, label: 'arm'}, + {pattern: /MacIntel/i, label: 'x64'}, + {pattern: /\bx86_64\b|\bx64\b|\bamd64\b|\bwin64\b|\bwow64\b/i, label: 'x64'}, + {pattern: /\bx86\b|\bi[3-6]86\b/i, label: 'x86'}, +]; + +export const normalizeArchitectureValue = (value: string | null | undefined): string | undefined => { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + for (const entry of ARCHITECTURE_PATTERNS) { + if (entry.pattern.test(trimmed)) { + return entry.label; + } + } + return trimmed || undefined; +}; + +const getNavigatorObject = (): Navigator | undefined => { + if (typeof navigator === 'undefined') { + return undefined; + } + return navigator; +}; + +const detectAppleSiliconViaWebGL = (): string | undefined => { + if (typeof document === 'undefined') { + return undefined; + } + const canvas = document.createElement('canvas'); + const gl = + (canvas.getContext('webgl') as WebGLRenderingContext | null) ?? + (canvas.getContext('experimental-webgl') as WebGLRenderingContext | null); + if (!gl) { + return undefined; + } + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (!ext) { + return undefined; + } + const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); + if (typeof renderer !== 'string') { + return undefined; + } + if (/apple m|apple gpu/i.test(renderer)) { + return 'arm64'; + } + if (/intel/i.test(renderer)) { + return 'x64'; + } + return undefined; +}; + +const isNavigatorPlatformMac = (nav: Navigator): boolean => { + const platform = nav.platform ?? ''; + return /^(mac|darwin)/i.test(platform) || /Macintosh|Mac OS X/i.test(nav.userAgent ?? ''); +}; + +const detectArchitectureFromNavigator = (): string | undefined => { + const nav = getNavigatorObject(); + if (!nav) { + return undefined; + } + + const userAgentData = (nav as Navigator & {userAgentData?: NavigatorUADataLike}).userAgentData; + if (userAgentData?.architecture) { + return normalizeArchitectureValue(userAgentData.architecture); + } + + const userAgent = nav.userAgent ?? ''; + const platform = nav.platform ?? ''; + const isMac = isNavigatorPlatformMac(nav); + + if (isMac) { + const detected = detectAppleSiliconViaWebGL(); + if (detected) { + return detected; + } + } + + for (const entry of ARCHITECTURE_PATTERNS) { + if (entry.pattern.test(userAgent)) { + if (isMac && entry.label === 'x64') { + continue; + } + return entry.label; + } + } + + for (const entry of ARCHITECTURE_PATTERNS) { + if (entry.pattern.test(platform)) { + if (isMac && entry.label === 'x64') { + continue; + } + return entry.label; + } + } + + return undefined; +}; + let cachedClientInfo: ClientInfo | null = null; let preloadPromise: Promise | null = null; @@ -43,12 +158,15 @@ const parseUserAgent = (): ClientInfo => { const userAgent = hasNavigator ? navigator.userAgent : ''; const parser = Bowser.getParser(userAgent); const result = parser.getResult(); + const isMac = hasNavigator && isNavigatorPlatformMac(navigator); + const fallbackArch = hasNavigator && !isMac ? normalizeArchitectureValue(navigator.platform) : undefined; + const arch = detectArchitectureFromNavigator() ?? fallbackArch; return { browserName: normalize(result.browser.name), browserVersion: normalize(result.browser.version), osName: normalize(result.os.name), osVersion: normalize(result.os.version), - arch: normalize(hasNavigator ? navigator.platform : undefined), + arch: arch, }; }; @@ -85,8 +203,9 @@ async function getDesktopContext(): Promise> { return { desktopVersion: normalize(desktopInfo.version), desktopChannel: normalize(desktopInfo.channel), - desktopArch: normalize(desktopInfo.arch), + desktopArch: normalizeArchitectureValue(desktopInfo.hardwareArch ?? desktopInfo.arch), desktopOS: normalize(desktopInfo.os), + desktopRunningUnderRosetta: desktopInfo.runningUnderRosetta, }; } catch (error) { console.warn('[ClientInfo] Failed to load desktop context', error); @@ -112,13 +231,51 @@ function getWindowsVersionName(osVersion: string): string { return 'Windows'; } +const detectArchitectureFromClientHints = async (): Promise => { + const nav = getNavigatorObject(); + if (!nav) { + return undefined; + } + const userAgentData = (nav as Navigator & {userAgentData?: NavigatorUADataLike}).userAgentData; + if (!userAgentData?.getHighEntropyValues) { + return undefined; + } + + try { + const hints = await userAgentData.getHighEntropyValues(['architecture', 'bitness']); + const archHint = hints.architecture?.toLowerCase() ?? ''; + const bitness = hints.bitness?.toLowerCase() ?? ''; + const platform = (userAgentData.platform ?? '').toLowerCase(); + + if (platform === 'windows') { + if (archHint === 'arm') { + return 'arm64'; + } + if (archHint === 'x86' && bitness === '64') { + return 'x64'; + } + } + + if (archHint.includes('arm')) { + return 'arm64'; + } + if (archHint.includes('intel') || archHint.includes('x64')) { + return 'x64'; + } + + return normalizeArchitectureValue(archHint); + } catch (error) { + console.warn('[ClientInfo] Failed to load architecture hints', error); + return undefined; + } +}; + async function getOsContext(): Promise> { const electronApi = getElectronAPI(); if (electronApi) { try { const desktopInfo = await electronApi.getDesktopInfo(); let osName: string | undefined; - let osVersion: string | undefined; switch (desktopInfo.os) { case 'darwin': @@ -126,7 +283,6 @@ async function getOsContext(): Promise> { break; case 'win32': osName = getWindowsVersionName(desktopInfo.osVersion); - osVersion = desktopInfo.osVersion; break; case 'linux': osName = 'Linux'; @@ -136,8 +292,8 @@ async function getOsContext(): Promise> { } return { osName, - osVersion, - arch: normalize(desktopInfo.arch), + osVersion: normalize(desktopInfo.osVersion), + arch: normalizeArchitectureValue(desktopInfo.arch), }; } catch (error) { console.warn('[ClientInfo] Failed to load OS context', error); @@ -150,7 +306,10 @@ async function getOsContext(): Promise> { export const getClientInfo = async (): Promise => { const base = getClientInfoSync(); - if (!isDesktop()) return base; + if (!isDesktop()) { + const hintsArch = await detectArchitectureFromClientHints(); + return {...base, arch: hintsArch ?? base.arch}; + } const [osContext, desktop] = await Promise.all([getOsContext(), getDesktopContext()]); return {...base, ...osContext, ...desktop};