@dfsol/platform-detector
Version:
Universal platform detector for web, PWA, Telegram Mini Apps, and native mobile applications with Client Hints API, feature detection, and enhanced accuracy
744 lines (743 loc) • 27.4 kB
JavaScript
import { getTelegramWebApp } from './telegram-sdk.js';
import { isTmaJsSdkAvailable, isTelegramViaTmaJs, getTelegramPlatformFromTmaJs, getTelegramInitDataFromTmaJs, getViewportFromTmaJs, getThemeParamsFromTmaJs, getTmaJsSdk } from './tma-sdk.js';
import { ClientHintsDetector } from './client-hints.js';
/**
* Detects the current platform and environment
*/
export class PlatformDetector {
options;
cache;
cacheTimestamp;
constructor(options = {}) {
this.options = {
useClientHints: false,
useFeatureDetection: true,
cacheTTL: 5000, // 5 seconds default
...options
};
}
/**
* Detect platform information with priority-based detection
* Supports caching to improve performance
*/
detect() {
// Check cache
if (this.cache && this.cacheTimestamp) {
const now = Date.now();
const cacheTTL = this.options.cacheTTL || 5000;
if (now - this.cacheTimestamp < cacheTTL) {
return this.cache;
}
}
// Perform detection
const result = this.performDetection();
// Update cache
this.cache = result;
this.cacheTimestamp = Date.now();
return result;
}
/**
* Perform actual detection logic
*/
performDetection() {
if (typeof window === 'undefined') {
return this.createServerSideInfo();
}
const userAgent = this.options.userAgent || navigator.userAgent;
const hostname = this.options.hostname || window.location.hostname;
// Basic detection
const os = this.detectOS(userAgent);
const device = this.detectDevice(userAgent, os);
const domainMode = this.detectDomainMode(hostname);
const environment = this.detectEnvironment();
// Priority-based platform detection
// 1. Check for native wrappers first (most specific)
const isNativeCapacitor = this.detectNative();
const capacitor = this.getCapacitorInfo();
const isNative = isNativeCapacitor && capacitor?.isNativePlatform === true;
// 2. Check for Telegram Mini App
const isTelegram = this.detectTelegram();
const telegram = this.getTelegramInfo();
// 3. Check for PWA
const isPWA = this.detectPWA();
// Determine primary platform type based on priority
let type = 'web';
if (isNative) {
type = 'native';
}
else if (isTelegram) {
type = 'tma';
}
else if (isPWA) {
type = 'pwa';
}
// Check if TMA warning should be shown
const shouldShowTMAWarning = domainMode === 'tma' && !isTelegram;
// Get screen info
const screen = this.getScreenInfo();
// Determine device type considering platform-specific info
let finalDevice = device;
let finalIsMobile = device === 'mobile' || device === 'tablet';
let finalOS = os;
// Adjust based on Telegram platform info
if (isTelegram && telegram?.platform) {
const tgPlatform = telegram.platform;
// Mobile platforms
if (tgPlatform === 'ios' || tgPlatform === 'android' || tgPlatform === 'android_x') {
finalDevice = device === 'tablet' ? 'tablet' : 'mobile';
finalIsMobile = true;
// Correct OS if needed
if (tgPlatform === 'ios' && os !== 'ios')
finalOS = 'ios';
if ((tgPlatform === 'android' || tgPlatform === 'android_x') && os !== 'android')
finalOS = 'android';
}
// Desktop platforms
else if (tgPlatform === 'macos' || tgPlatform === 'tdesktop') {
finalDevice = 'desktop';
finalIsMobile = false;
if (tgPlatform === 'macos' && os !== 'macos')
finalOS = 'macos';
}
// Web platforms - keep original detection
}
// Browser family detection
const browserFamily = this.detectBrowserFamily(userAgent);
// Calculate confidence
const confidence = this.calculateConfidence(userAgent, {
os: finalOS,
device: finalDevice,
browserFamily
});
const info = {
type,
os: finalOS,
device: finalDevice,
domainMode,
environment,
isPWA,
isTelegram,
isNative,
isWeb: !isNative && !isTelegram,
isMobile: finalIsMobile,
isDesktop: !finalIsMobile,
isIOS: finalOS === 'ios',
isAndroid: finalOS === 'android',
isMacOS: finalOS === 'macos',
isWindows: finalOS === 'windows',
isLinux: finalOS === 'linux',
isChromeOS: finalOS === 'chromeos',
userAgent,
shouldShowTMAWarning,
screen,
browserFamily,
confidence,
capacitor,
telegram
};
if (this.options.debug) {
console.log('[PlatformDetector]', info);
}
return info;
}
/**
* Detect operating system from user agent with enhanced iPadOS detection
*/
detectOS(userAgent) {
const ua = userAgent.toLowerCase();
const platform = typeof window !== 'undefined' ? window.navigator.platform : '';
// iOS detection (including iPadOS 13+)
if (/iphone|ipad|ipod/.test(ua))
return 'ios';
// iPadOS 13+ detection (reports as Mac but has touch support)
if (platform === 'MacIntel' && typeof window !== 'undefined' && navigator.maxTouchPoints > 1) {
return 'ios';
}
// Android detection
if (/android/.test(ua))
return 'android';
// Desktop OS detection
if (/cros/.test(ua))
return 'chromeos';
if (/mac os x|macintosh/.test(ua))
return 'macos';
if (/windows|win32|win64/.test(ua))
return 'windows';
if (/linux|x11/.test(ua) && !/android/.test(ua))
return 'linux';
return 'unknown';
}
/**
* Detect device type from user agent and OS with enhanced tablet detection
*/
detectDevice(userAgent, os) {
const ua = userAgent.toLowerCase();
const width = typeof window !== 'undefined' ? window.innerWidth : 0;
// iOS device detection
if (os === 'ios') {
// iPad detection (including iPadOS 13+)
if (/ipad/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) {
return 'tablet';
}
return 'mobile';
}
// Android device detection
if (os === 'android') {
// Android tablet detection - tablets typically don't have 'mobile' in UA
if (!/mobile/.test(ua) && /android/.test(ua)) {
return 'tablet';
}
// Additional check for specific tablet models
if (/tablet|tab\d+/.test(ua)) {
return 'tablet';
}
return 'mobile';
}
// Check screen width as additional hint for ambiguous cases
if (width > 0 && width <= 768 && /mobi/.test(ua)) {
return 'mobile';
}
// Desktop OS = desktop device
return 'desktop';
}
/**
* Detect domain mode (app.* vs tg.*)
*/
detectDomainMode(hostname) {
if (hostname.startsWith('app.'))
return 'app';
if (hostname.startsWith('tg.'))
return 'tma';
return 'unknown';
}
/**
* Detect environment (development vs production)
*/
detectEnvironment() {
// Use override if provided
if (this.options.environment) {
return this.options.environment;
}
if (typeof window === 'undefined')
return 'unknown';
const hostname = this.options.hostname || window.location.hostname;
// Development indicators
if (hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.includes('dev.') ||
hostname.includes('staging.') ||
hostname.includes('.local')) {
return 'development';
}
// Production indicators
if (hostname.includes('.com') ||
hostname.includes('.org') ||
hostname.includes('.net') ||
hostname.includes('.io') ||
hostname.includes('.app') ||
hostname.includes('.cc')) {
return 'production';
}
return 'unknown';
}
/**
* Detect if running as native mobile app (Capacitor/Cordova)
*/
detectNative() {
if (typeof window === 'undefined')
return false;
// Check for Capacitor
if ('Capacitor' in window) {
return true;
}
// Check for Cordova
if ('cordova' in window || 'phonegap' in window) {
return true;
}
// Check for Capacitor's native flag
if (document.documentElement.classList.contains('capacitor')) {
return true;
}
return false;
}
/**
* Detect if running in Telegram Mini App with strict checks
* Supports both native Telegram SDK and @tma.js SDK
*/
detectTelegram() {
if (typeof window === 'undefined')
return false;
// Priority 1: Check @tma.js SDK (more reliable)
if (isTelegramViaTmaJs()) {
return true;
}
// Priority 2: Use provided Telegram object if available
if (this.options.telegramWebApp) {
return this.validateTelegramWebApp(this.options.telegramWebApp);
}
// Priority 3: Check for native Telegram WebApp
const webApp = getTelegramWebApp();
if (!webApp)
return false;
return this.validateTelegramWebApp(webApp);
}
/**
* Validate Telegram WebApp with flexible checks for native app compatibility
*/
validateTelegramWebApp(webApp) {
// WebApp must exist and have a version
if (!webApp || !webApp.version) {
return false;
}
// Check if we have actual Telegram init data
const hasInitData = !!(webApp.initData ||
(webApp.initDataUnsafe && Object.keys(webApp.initDataUnsafe).length > 0));
// Check for platform
const hasPlatform = !!webApp.platform && webApp.platform !== 'unknown';
// Check for colorScheme
const hasColorScheme = !!webApp.colorScheme;
// Native Telegram apps might not have initData immediately on launch
// Platform + colorScheme + version is sufficient for native app detection
// If initData is present, require all three indicators for highest confidence
if (hasInitData) {
return hasPlatform && hasColorScheme;
}
// Without initData, we need strong platform indicators
// This handles native Telegram app launches where initData loads asynchronously
return hasPlatform && hasColorScheme;
}
/**
* Detect if running as PWA (installed) with comprehensive checks
* Updated for 2025 standards including window-controls-overlay
*/
detectPWA() {
if (typeof window === 'undefined')
return false;
// Check various display modes (including 2025 additions)
const displayModes = [
'standalone',
'fullscreen',
'minimal-ui',
'window-controls-overlay' // Already included - Chrome 2025 desktop PWA
];
for (const mode of displayModes) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) {
return true;
}
}
// iOS Safari standalone mode (pre-iOS 15 compatibility)
if ('standalone' in navigator && navigator.standalone === true) {
return true;
}
// NEW: Window Controls Overlay API check (2025)
// For desktop PWAs with custom title bar
if (navigator.windowControlsOverlay?.visible) {
return true;
}
return false;
}
/**
* Get detailed Capacitor information from native API
*/
getCapacitorInfo() {
if (typeof window === 'undefined' || !('Capacitor' in window)) {
return undefined;
}
const Capacitor = window.Capacitor;
const isNativePlatform = Capacitor.isNativePlatform
? Capacitor.isNativePlatform()
: false;
const platformName = Capacitor.getPlatform ? Capacitor.getPlatform() : 'unknown';
let platform = 'unknown';
if (platformName === 'ios')
platform = 'ios';
else if (platformName === 'android')
platform = 'android';
else if (platformName === 'web')
platform = 'web';
return {
isNativePlatform,
platform,
isPluginAvailable: (plugin) => {
return Capacitor.isPluginAvailable ? Capacitor.isPluginAvailable(plugin) : false;
},
nativeVersion: Capacitor.nativeVersion
};
}
/**
* Get detailed Telegram information from WebApp SDK
* Supports both native Telegram SDK and @tma.js SDK
*/
getTelegramInfo() {
let sdkSource = 'unknown';
// Priority 1: Try @tma.js SDK
if (isTmaJsSdkAvailable()) {
const info = this.getTelegramInfoFromTmaJs();
if (info)
return info;
}
// Priority 2: Try native Telegram WebApp
const webApp = this.options.telegramWebApp || getTelegramWebApp();
if (!webApp) {
return undefined;
}
sdkSource = 'native';
return {
platform: (webApp.platform || 'unknown'),
version: webApp.version || '0.0',
sdkSource,
colorScheme: webApp.colorScheme || 'dark',
viewportHeight: webApp.viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 0),
viewportStableHeight: webApp.viewportStableHeight || (typeof window !== 'undefined' ? window.innerHeight : 0),
isExpanded: webApp.isExpanded || false,
isClosingConfirmationEnabled: webApp.isClosingConfirmationEnabled || false,
headerColor: webApp.headerColor || '#000000',
backgroundColor: webApp.backgroundColor || '#ffffff',
safeAreaInsetTop: webApp.safeAreaInset?.top || 0,
safeAreaInsetBottom: webApp.safeAreaInset?.bottom || 0,
contentSafeAreaInsetTop: webApp.contentSafeAreaInset?.top || 0,
contentSafeAreaInsetBottom: webApp.contentSafeAreaInset?.bottom || 0,
initData: webApp.initData,
user: webApp.initDataUnsafe?.user
};
}
/**
* Get Telegram information from @tma.js SDK
*/
getTelegramInfoFromTmaJs() {
const sdk = getTmaJsSdk();
if (!sdk)
return undefined;
const viewport = getViewportFromTmaJs();
const themeParams = getThemeParamsFromTmaJs();
const platform = getTelegramPlatformFromTmaJs();
const initData = getTelegramInitDataFromTmaJs();
const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
return {
platform: (platform || 'unknown'),
version: sdk.version || '0.0',
sdkSource: 'tma.js',
colorScheme: themeParams?.backgroundColor?.includes('#') ? 'dark' : 'light', // Simple heuristic
viewportHeight: viewport?.height || windowHeight,
viewportStableHeight: viewport?.stableHeight || windowHeight,
isExpanded: viewport?.isExpanded || false,
isClosingConfirmationEnabled: false, // Not available in @tma.js
headerColor: sdk.miniApp?.headerColor || themeParams?.backgroundColor || '#000000',
backgroundColor: sdk.miniApp?.backgroundColor || themeParams?.backgroundColor || '#ffffff',
safeAreaInsetTop: 0, // Not directly available in @tma.js
safeAreaInsetBottom: 0,
contentSafeAreaInsetTop: 0,
contentSafeAreaInsetBottom: 0,
initData: initData || undefined,
user: sdk.initData?.parsed?.user,
themeParams: themeParams || undefined
};
}
/**
* Get screen information
*/
getScreenInfo() {
if (typeof window === 'undefined') {
return { width: 0, height: 0, pixelRatio: 1 };
}
return {
width: window.innerWidth,
height: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1
};
}
/**
* Create server-side platform info (SSR)
*/
createServerSideInfo() {
return {
type: 'web',
os: 'unknown',
device: 'desktop',
domainMode: 'unknown',
environment: this.options.environment || 'unknown',
isPWA: false,
isTelegram: false,
isNative: false,
isWeb: true,
isMobile: false,
isDesktop: true,
isIOS: false,
isAndroid: false,
isMacOS: false,
isWindows: false,
isLinux: false,
isChromeOS: false,
userAgent: '',
shouldShowTMAWarning: false,
screen: { width: 0, height: 0, pixelRatio: 1 }
};
}
/**
* Check Telegram Mini App (TMA) availability and generate deep link
*/
checkTMAAvailability(botUsername) {
if (this.detectTelegram()) {
return {
isAvailable: true
};
}
// Generate Telegram bot URL for deep linking
const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
const encodedUrl = encodeURIComponent(currentUrl);
const botUrl = `https://t.me/${botUsername}?start=${encodedUrl}`;
return {
isAvailable: false,
reason: 'Not running in Telegram Mini App environment',
botUrl
};
}
/**
* Attempt to open URL in Telegram
*/
openInTelegram(botUsername) {
if (typeof window === 'undefined')
return false;
const availability = this.checkTMAAvailability(botUsername);
if (availability.isAvailable) {
// Already in Telegram
return true;
}
if (availability.botUrl) {
// Try to open in Telegram
window.location.href = availability.botUrl;
return true;
}
return false;
}
/**
* Get browser type (Chrome, Safari, Firefox, Edge, etc.)
*/
getBrowserType() {
if (typeof window === 'undefined')
return 'unknown';
const ua = navigator.userAgent.toLowerCase();
// Check Edge first (includes "Edg/")
if (/edg/i.test(ua))
return 'Edge';
// Firefox (includes fxios for iOS)
if (/firefox|fxios/i.test(ua))
return 'Firefox';
// Opera
if (/opr|opera/i.test(ua))
return 'Opera';
// Chrome (exclude Opera, Chromium, Edge)
if (/chrome|crios/i.test(ua) && !/opr|opera|chromium|edg/i.test(ua)) {
return 'Chrome';
}
// Safari (exclude Chrome and other browsers)
if (/safari/i.test(ua) && !/chromium|edg|chrome|crios|firefox/i.test(ua)) {
return 'Safari';
}
// Samsung Internet
if (/samsungbrowser/i.test(ua))
return 'Samsung Internet';
// UC Browser
if (/ucbrowser/i.test(ua))
return 'UC Browser';
return 'unknown';
}
/**
* Detect browser family (rendering engine)
*/
detectBrowserFamily(userAgent) {
const ua = userAgent.toLowerCase();
// Chromium-based (Chrome, Edge, Opera, Samsung Internet)
if (window.chrome !== undefined ||
/chrome|crios|edg|opr|samsungbrowser/i.test(ua)) {
return 'chromium';
}
// WebKit (Safari)
if (/webkit/i.test(ua) && !/chrome|crios/i.test(ua)) {
return 'webkit';
}
// Gecko (Firefox)
if (/gecko/i.test(ua) && !/webkit/i.test(ua)) {
return 'gecko';
}
return 'unknown';
}
/**
* Calculate detection confidence score
*/
calculateConfidence(userAgent, detected) {
let overall = 100;
let osConfidence = 100;
let deviceConfidence = 100;
let browserConfidence = 100;
// Reduce confidence for frozen/reduced user agents
if (ClientHintsDetector.isFrozenUA(userAgent)) {
overall -= 20;
osConfidence -= 30;
deviceConfidence -= 20;
}
// Reduce confidence if Client Hints not used but available
if (ClientHintsDetector.isSupported() && !this.options.useClientHints) {
overall -= 10;
osConfidence -= 15;
}
// Reduce confidence for unknown values
if (detected.os === 'unknown') {
overall -= 25;
osConfidence = 30;
}
if (detected.browserFamily === 'unknown') {
overall -= 10;
browserConfidence -= 20;
}
// Feature detection increases confidence
if (this.options.useFeatureDetection) {
overall += 5;
deviceConfidence += 10;
}
// Ensure values are within 0-100 range
return {
overall: Math.max(0, Math.min(100, overall)),
os: Math.max(0, Math.min(100, osConfidence)),
device: Math.max(0, Math.min(100, deviceConfidence)),
browser: Math.max(0, Math.min(100, browserConfidence))
};
}
/**
* Get display mode for PWA
*/
getDisplayMode() {
if (typeof window === 'undefined')
return 'browser';
const modes = ['fullscreen', 'standalone', 'minimal-ui', 'window-controls-overlay'];
for (const mode of modes) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) {
return mode;
}
}
// iOS specific
if ('standalone' in navigator && navigator.standalone) {
return 'standalone-ios';
}
return 'browser';
}
/**
* Check if the app can be installed as PWA
*/
isPWAInstallable() {
if (typeof window === 'undefined')
return false;
// Check if already installed
if (this.detectPWA())
return false;
// Check for manifest
const hasManifest = document.querySelector('link[rel="manifest"]') !== null;
// Check for service worker support
const hasServiceWorker = 'serviceWorker' in navigator;
// Check for HTTPS or localhost
const isSecure = location.protocol === 'https:' || location.hostname === 'localhost';
return hasManifest && hasServiceWorker && isSecure;
}
/**
* Monitor for platform changes
*/
watchForChanges(callback) {
if (typeof window === 'undefined') {
return () => { };
}
const checkAndNotify = () => {
const newInfo = this.detect();
callback(newInfo);
};
// Watch for display mode changes (PWA installation/uninstallation)
const displayModeQuery = window.matchMedia('(display-mode: standalone)');
displayModeQuery.addEventListener('change', checkAndNotify);
// Watch for online/offline changes
window.addEventListener('online', checkAndNotify);
window.addEventListener('offline', checkAndNotify);
// Watch for Telegram viewport changes if available
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.onEvent('viewportChanged', checkAndNotify);
}
// Return cleanup function
return () => {
displayModeQuery.removeEventListener('change', checkAndNotify);
window.removeEventListener('online', checkAndNotify);
window.removeEventListener('offline', checkAndNotify);
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.offEvent('viewportChanged', checkAndNotify);
}
};
}
/**
* Async detection with Client Hints support
* Provides enhanced accuracy for OS version and device info on Chrome/Edge
*/
async detectAsync() {
// Get base detection
const baseInfo = this.detect();
// If Client Hints not supported or disabled, return base detection
if (!ClientHintsDetector.isSupported() || this.options.useClientHints === false) {
return baseInfo;
}
try {
// Get Client Hints data
const hints = await ClientHintsDetector.detect();
if (!hints) {
return baseInfo;
}
// Merge with base detection
return {
...baseInfo,
// Override with Client Hints data if available
os: hints.os || baseInfo.os,
osVersion: hints.osVersion || baseInfo.osVersion,
device: hints.device || baseInfo.device,
architecture: hints.architecture || baseInfo.architecture,
deviceModel: hints.model || baseInfo.deviceModel,
// Update boolean flags based on new OS
isIOS: (hints.os || baseInfo.os) === 'ios',
isAndroid: (hints.os || baseInfo.os) === 'android',
isMacOS: (hints.os || baseInfo.os) === 'macos',
isWindows: (hints.os || baseInfo.os) === 'windows',
isLinux: (hints.os || baseInfo.os) === 'linux',
isChromeOS: (hints.os || baseInfo.os) === 'chromeos',
// Update mobile/desktop flags
isMobile: hints.device === 'mobile' || hints.device === 'tablet'
? true
: baseInfo.isMobile,
isDesktop: hints.device === 'desktop'
? true
: baseInfo.isDesktop
};
}
catch (error) {
if (this.options.debug) {
console.error('[PlatformDetector] Client Hints detection failed:', error);
}
return baseInfo;
}
}
/**
* Clear detection cache
*/
clearCache() {
this.cache = undefined;
this.cacheTimestamp = undefined;
}
}
/**
* Create a platform detector instance
*/
export function createPlatformDetector(options) {
return new PlatformDetector(options);
}
/**
* Quick platform detection (convenience function)
*/
export function detectPlatform(options) {
const detector = new PlatformDetector(options);
return detector.detect();
}