UNPKG

@dfsol/platform-detector

Version:

Universal platform detector for web, PWA, TWA, Telegram Mini Apps, Capacitor.js, and native mobile applications with @tma.js SDK support

469 lines (468 loc) 16.9 kB
import { getTelegramWebApp } from './telegram-sdk.js'; import { isTmaJsSdkAvailable, isTelegramViaTmaJs, getTelegramPlatformFromTmaJs, getTelegramInitDataFromTmaJs, getViewportFromTmaJs, getThemeParamsFromTmaJs, getTmaJsSdk } from './tma-sdk.js'; /** * Detects the current platform and environment */ export class PlatformDetector { options; constructor(options = {}) { this.options = options; } /** * Detect platform information */ detect() { if (typeof window === 'undefined') { return this.createServerSideInfo(); } const userAgent = this.options.userAgent || navigator.userAgent; const hostname = this.options.hostname || window.location.hostname; const os = this.detectOS(userAgent); const device = this.detectDevice(userAgent, os); const domainMode = this.detectDomainMode(hostname); const environment = this.detectEnvironment(); const isNativeCapacitor = this.detectNative(); const isTelegram = this.detectTelegram(); const isPWA = this.detectPWA(); const isTWA = this.detectTWA(); // Get detailed info from native APIs const capacitor = this.getCapacitorInfo(); const telegram = this.getTelegramInfo(); // Determine if truly native (Capacitor on native platform) const isNative = isNativeCapacitor && capacitor?.isNativePlatform === true; // Determine primary platform type let type = 'web'; if (isNative) type = 'native'; else if (isTelegram) type = 'tma'; else if (isTWA) type = 'twa'; 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 Telegram platform let finalDevice = device; let finalIsMobile = device === 'mobile' || device === 'tablet'; if (isTelegram && telegram?.platform) { // For TMA, trust Telegram's platform info const tgPlatform = telegram.platform; if (tgPlatform === 'ios' || tgPlatform === 'android' || tgPlatform === 'android_x') { finalDevice = 'mobile'; finalIsMobile = true; } else if (tgPlatform === 'macos' || tgPlatform === 'tdesktop') { finalDevice = 'desktop'; finalIsMobile = false; } // weba, webk, web - keep device detection from user agent } const info = { type, os, device: finalDevice, domainMode, environment, isPWA, isTWA, isTelegram, isNative, isWeb: !isNative, isMobile: finalIsMobile, isDesktop: !finalIsMobile, isIOS: os === 'ios', isAndroid: os === 'android', isMacOS: os === 'macos', isWindows: os === 'windows', isLinux: os === 'linux', isChromeOS: os === 'chromeos', userAgent, shouldShowTMAWarning, screen, capacitor, telegram }; if (this.options.debug) { console.log('[PlatformDetector]', info); } return info; } /** * Detect operating system from user agent */ detectOS(userAgent) { const ua = userAgent.toLowerCase(); // Mobile OS detection (highest priority) if (/iphone|ipad|ipod/.test(ua)) return 'ios'; 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 */ detectDevice(userAgent, os) { const ua = userAgent.toLowerCase(); // Mobile OS = mobile device if (os === 'ios' || os === 'android') { // Check for tablet if (/ipad/.test(ua) || (/android/.test(ua) && !/mobile/.test(ua))) { return 'tablet'; } 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) */ detectPWA() { if (typeof window === 'undefined') return false; // Check display mode if (window.matchMedia('(display-mode: standalone)').matches) { return true; } // Check minimal-ui mode if (window.matchMedia('(display-mode: minimal-ui)').matches) { return true; } // iOS Safari standalone mode if ('standalone' in navigator && navigator.standalone) { return true; } return false; } /** * Detect if running as TWA (Trusted Web Activity on Android) */ detectTWA() { if (typeof window === 'undefined') return false; // TWA is identified by Android app referrer if (document.referrer.includes('android-app://')) { return true; } // Check for TWA-specific display mode with Android UA const userAgent = navigator.userAgent.toLowerCase(); if (userAgent.includes('android') && window.matchMedia('(display-mode: standalone)').matches) { // Could be TWA or PWA, check for TWA-specific indicators // TWA doesn't have Service Worker registered in some cases if (!navigator.serviceWorker) { 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, isTWA: 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 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; } } /** * 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(); }