diff --git a/package.json b/package.json index 0797cd3..567d4c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ttrpg-player-landing", "private": true, - "version": "1.0.6", + "version": "1.0.7", "description": "Marketing landing for TTRPG Player (НРИ Плеер)", "type": "module", "scripts": { diff --git a/src/components/sections/Platforms.module.css b/src/components/sections/Platforms.module.css index 245ddbe..112f7dd 100644 --- a/src/components/sections/Platforms.module.css +++ b/src/components/sections/Platforms.module.css @@ -28,6 +28,22 @@ .downloadBtn { width: 100%; + white-space: normal; + text-wrap: balance; +} + +.downloadStack { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.archHint { + margin: 2px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--color-text-dim); } .note { diff --git a/src/components/sections/Platforms.tsx b/src/components/sections/Platforms.tsx index bfd73a1..a02c183 100644 --- a/src/components/sections/Platforms.tsx +++ b/src/components/sections/Platforms.tsx @@ -1,8 +1,11 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { DOWNLOAD_LINKS, PLATFORM_IDS } from "../../data/siteContent"; import { useLocale } from "../../i18n/LocaleProvider"; import { + type CpuArch, + detectCpuArch, + detectCpuArchAsync, detectOs, resolveDownloadHref, resolvePlatformDownloadHref, @@ -24,19 +27,54 @@ function readNavigatorHints(): { userAgent: string; maxTouchPoints: number } { }; } +function macArchLabel( + arch: "arm64" | "x64", + labels: { arm64: string; x64: string; recommended: string }, + preferred: CpuArch, +): string { + const base = arch === "arm64" ? labels.arm64 : labels.x64; + if (preferred === arch) return `${base} · ${labels.recommended}`; + return base; +} + export function Platforms() { const { copy } = useLocale(); const hints = useMemo(() => readNavigatorHints(), []); + const [cpuArch, setCpuArch] = useState(() => + detectCpuArch(hints.userAgent, hints.maxTouchPoints), + ); - const { osDownloadHref, detectedOs } = useMemo(() => { - const os = detectOs(hints.userAgent); - return { - osDownloadHref: resolveDownloadHref(os, DOWNLOAD_LINKS, hints), - detectedOs: os, + useEffect(() => { + let cancelled = false; + void detectCpuArchAsync(hints).then((arch) => { + if (!cancelled) setCpuArch(arch); + }); + return () => { + cancelled = true; }; }, [hints]); + const archHints = useMemo( + () => ({ + userAgent: hints.userAgent, + maxTouchPoints: hints.maxTouchPoints, + uaArchitecture: + cpuArch === "arm64" ? "arm" : cpuArch === "x64" ? "x86" : null, + }), + [hints, cpuArch], + ); + + const detectedOs = useMemo( + () => detectOs(hints.userAgent), + [hints.userAgent], + ); + + const osDownloadHref = useMemo( + () => resolveDownloadHref(detectedOs, DOWNLOAD_LINKS, archHints), + [detectedOs, archHints], + ); + const trackOsDownload = () => { const platform = detectedOs === "windows" || @@ -52,12 +90,15 @@ export function Platforms() { Object.fromEntries( PLATFORM_IDS.map((id) => [ id, - resolvePlatformDownloadHref(id, DOWNLOAD_LINKS, hints), + resolvePlatformDownloadHref(id, DOWNLOAD_LINKS, archHints), ]), ) as Record<(typeof PLATFORM_IDS)[number], string>, - [hints], + [archHints], ); + const macPreferredArch: CpuArch = + detectedOs === "macos" ? cpuArch : "unknown"; + return (
@@ -68,16 +109,55 @@ export function Platforms() {

{platform.title}

{platform.description}

- + + {id === "macos" ? ( +
+ + +

+ {copy.platforms.macosArch.hint} +

+
+ ) : ( + + )} ); })} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index d6f7f28..e5cd9ce 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -168,13 +168,20 @@ const ru = { }, macos: { title: "macOS", - description: "Intel (x64) и Apple Silicon (M1/M2/M3). DMG.", + description: + "Отдельные DMG для Intel и Apple Silicon (M1/M2/M3). Выберите архитектуру ниже.", }, linux: { title: "Linux", description: "x64 и ARM64. AppImage.", }, }, + macosArch: { + arm64: "Apple Silicon", + x64: "Intel", + recommended: "рекомендуется", + hint: " → Об этом Mac → Чип / Процессор", + }, note: "Автообновления для лицензированных пользователей · Интерфейс на русском и английском · Работает офлайн", distributionDisclaimer: "Установочные файлы не подписаны сертификатом издателя и предоставляются «как есть» (as is), без каких‑либо явных или подразумеваемых гарантий. Разработчик не несёт ответственности за ущерб, связанный с загрузкой, установкой или использованием приложения. Устанавливая программу, вы принимаете все риски и ответственность за последствия на своей стороне.", @@ -438,13 +445,20 @@ const en: SiteCopy = { }, macos: { title: "macOS", - description: "Intel (x64) and Apple Silicon (M1/M2/M3). DMG.", + description: + "Separate DMGs for Intel and Apple Silicon (M1/M2/M3). Pick your architecture below.", }, linux: { title: "Linux", description: "x64 and ARM64. AppImage.", }, }, + macosArch: { + arm64: "Apple Silicon", + x64: "Intel", + recommended: "recommended", + hint: " → About This Mac → Chip / Processor", + }, note: "Auto-updates for licensed users · Russian and English UI · Works offline", distributionDisclaimer: "Installers are not code-signed by the publisher and are provided “as is”, without express or implied warranties. The developer is not liable for any damage arising from downloading, installing or using the application. By installing the software, you assume all risks and responsibility on your side.", diff --git a/src/lib/detectOs.ts b/src/lib/detectOs.ts index 53227ca..1e898f6 100644 --- a/src/lib/detectOs.ts +++ b/src/lib/detectOs.ts @@ -3,9 +3,17 @@ import type { DownloadLinks, PlatformId } from "../data/siteContent"; export type OsId = "windows" | "macos" | "linux" | "unknown"; export type CpuArch = "x64" | "arm64" | "unknown"; -type NavigatorHints = { +export type NavigatorHints = { userAgent: string; maxTouchPoints?: number; + /** From Client Hints `architecture` when available (e.g. "arm", "x86") */ + uaArchitecture?: string | null; +}; + +type NavigatorUAData = { + getHighEntropyValues: ( + hints: string[], + ) => Promise<{ architecture?: string; bitness?: string }>; }; export function detectOs(userAgent: string): OsId { @@ -16,8 +24,39 @@ export function detectOs(userAgent: string): OsId { return "unknown"; } -/** Best-effort CPU arch from UA; macOS Apple Silicon often masquerades as MacIntel */ -export function detectCpuArch(userAgent: string, maxTouchPoints = 0): CpuArch { +/** Map Client Hints / UA-CH architecture token to CpuArch */ +export function parseUaDataArchitecture( + architecture: string | null | undefined, +): CpuArch { + if (!architecture) return "unknown"; + const arch = architecture.toLowerCase(); + if (arch === "arm" || arch.includes("arm64") || arch.includes("aarch64")) { + return "arm64"; + } + if ( + arch === "x86" || + arch.includes("x86_64") || + arch.includes("amd64") || + arch.includes("x64") + ) { + return "x64"; + } + return "unknown"; +} + +/** + * Best-effort CPU arch from UA / Client Hints. + * macOS Apple Silicon often masquerades as "Intel Mac OS X" in the UA string — + * prefer uaArchitecture or maxTouchPoints; otherwise return unknown (do not guess Intel). + */ +export function detectCpuArch( + userAgent: string, + maxTouchPoints = 0, + uaArchitecture?: string | null, +): CpuArch { + const fromHints = parseUaDataArchitecture(uaArchitecture); + if (fromHints !== "unknown") return fromHints; + const ua = userAgent.toLowerCase(); if (ua.includes("aarch64") || ua.includes("arm64")) return "arm64"; if (ua.includes("x86_64") || ua.includes("amd64") || ua.includes("win64")) { @@ -26,26 +65,65 @@ export function detectCpuArch(userAgent: string, maxTouchPoints = 0): CpuArch { if (/armv\d+/u.test(ua)) return "arm64"; if (ua.includes("mac")) { - return maxTouchPoints > 1 ? "arm64" : "x64"; + // Safari on Apple Silicon often reports maxTouchPoints > 1 + if (maxTouchPoints > 1) return "arm64"; + return "unknown"; } return "unknown"; } +/** Resolve arch using navigator.userAgentData when the browser exposes it */ +export async function detectCpuArchAsync( + hints: NavigatorHints, +): Promise { + const sync = detectCpuArch( + hints.userAgent, + hints.maxTouchPoints ?? 0, + hints.uaArchitecture, + ); + if (sync !== "unknown") return sync; + + if (typeof navigator === "undefined") return sync; + + const uaData = (navigator as Navigator & { userAgentData?: NavigatorUAData }) + .userAgentData; + if (!uaData?.getHighEntropyValues) return sync; + + try { + const values = await uaData.getHighEntropyValues(["architecture"]); + const fromUaData = parseUaDataArchitecture(values.architecture); + if (fromUaData !== "unknown") return fromUaData; + } catch { + // Permission / unsupported — keep sync result + } + + return sync; +} + +function macDownloadHref(arch: CpuArch, links: DownloadLinks): string { + // Prefer Apple Silicon when unsure — majority of current Macs; Intel remains selectable in UI + return arch === "x64" ? links.macos.x64 : links.macos.arm64; +} + +function linuxDownloadHref(arch: CpuArch, links: DownloadLinks): string { + return arch === "arm64" ? links.linux.arm64 : links.linux.x64; +} + export function resolveDownloadHref( os: OsId, links: DownloadLinks, hints: NavigatorHints, ): string { - const arch = detectCpuArch(hints.userAgent, hints.maxTouchPoints ?? 0); + const arch = detectCpuArch( + hints.userAgent, + hints.maxTouchPoints ?? 0, + hints.uaArchitecture, + ); if (os === "windows") return links.windows; - if (os === "macos") { - return arch === "x64" ? links.macos.x64 : links.macos.arm64; - } - if (os === "linux") { - return arch === "arm64" ? links.linux.arm64 : links.linux.x64; - } + if (os === "macos") return macDownloadHref(arch, links); + if (os === "linux") return linuxDownloadHref(arch, links); return links.windows; } @@ -58,15 +136,17 @@ export function resolvePlatformDownloadHref( if (platform === "windows") return links.windows; const os = detectOs(hints.userAgent); - const arch = detectCpuArch(hints.userAgent, hints.maxTouchPoints ?? 0); + const arch = detectCpuArch( + hints.userAgent, + hints.maxTouchPoints ?? 0, + hints.uaArchitecture, + ); if (platform === "macos") { - if (os === "macos") { - return arch === "x64" ? links.macos.x64 : links.macos.arm64; - } + if (os === "macos") return macDownloadHref(arch, links); return links.macos.arm64; } - if (os === "linux" && arch === "arm64") return links.linux.arm64; + if (os === "linux") return linuxDownloadHref(arch, links); return links.linux.x64; } diff --git a/src/siteContent.test.ts b/src/siteContent.test.ts index 796c531..1cedacd 100644 --- a/src/siteContent.test.ts +++ b/src/siteContent.test.ts @@ -25,6 +25,7 @@ import { MESSAGES, inferLocaleFromLanguages } from "./i18n/messages"; import { detectCpuArch, detectOs, + parseUaDataArchitecture, resolveDownloadHref, resolvePlatformDownloadHref, } from "./lib/detectOs"; @@ -121,6 +122,8 @@ describe("i18n messages", () => { assert.equal(MESSAGES.en.cta.download, "Download"); assert.equal(MESSAGES.ru.cta.buy, "Купить"); assert.equal(MESSAGES.en.cta.buy, "Buy"); + assert.equal(MESSAGES.ru.platforms.macosArch.arm64, "Apple Silicon"); + assert.equal(MESSAGES.en.platforms.macosArch.x64, "Intel"); }); }); @@ -156,6 +159,32 @@ describe("detectOs", () => { assert.equal(detectCpuArch("Mozilla/5.0 (X11; Linux aarch64)"), "arm64"); }); + it("parses Client Hints architecture tokens", () => { + assert.equal(parseUaDataArchitecture("arm"), "arm64"); + assert.equal(parseUaDataArchitecture("x86"), "x64"); + assert.equal(parseUaDataArchitecture("arm64"), "arm64"); + assert.equal(parseUaDataArchitecture(undefined), "unknown"); + }); + + it("does not guess Intel on opaque Mac user agents", () => { + assert.equal( + detectCpuArch("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 0), + "unknown", + ); + assert.equal( + detectCpuArch("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 5), + "arm64", + ); + assert.equal( + detectCpuArch( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + 0, + "arm", + ), + "arm64", + ); + }); + it("resolves download href by OS", () => { const hints = { userAgent: "Mozilla/5.0 (Windows NT 10.0)" }; assert.equal( @@ -166,9 +195,17 @@ describe("detectOs", () => { resolveDownloadHref("macos", DOWNLOAD_LINKS, { userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X)", maxTouchPoints: 0, + uaArchitecture: "x86", }), DOWNLOAD_LINKS.macos.x64, ); + assert.equal( + resolveDownloadHref("macos", DOWNLOAD_LINKS, { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X)", + maxTouchPoints: 0, + }), + DOWNLOAD_LINKS.macos.arm64, + ); assert.equal( resolveDownloadHref("linux", DOWNLOAD_LINKS, { userAgent: "Mozilla/5.0 (X11; Linux aarch64)",