feat(utils): add platform and browser detection helpers

This commit is contained in:
2026-06-18 10:27:03 +08:00
parent e4b1690b45
commit d2bced8889
5 changed files with 344 additions and 17 deletions
+1
View File
@@ -33,6 +33,7 @@
"react-dom": "19.2.4",
"react-icons": "^5.6.0",
"stripe": "^22.2.1",
"ua-parser-js": "^2.0.10",
"unstorage": "^1.17.5",
"xstate": "^5",
"zod": "^4.4.3"
+28
View File
@@ -53,6 +53,9 @@ importers:
stripe:
specifier: ^22.2.1
version: 22.2.1(@types/node@20.19.41)
ua-parser-js:
specifier: ^2.0.10
version: 2.0.10
unstorage:
specifier: ^1.17.5
version: 1.17.5
@@ -1639,6 +1642,9 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2147,6 +2153,9 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
is-standalone-pwa@0.1.1:
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -3081,6 +3090,13 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-is-frozen@0.1.2:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
ua-parser-js@2.0.10:
resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@@ -4689,6 +4705,8 @@ snapshots:
destr@2.0.5: {}
detect-europe-js@0.1.2: {}
detect-libc@2.1.2: {}
dexie@4.4.3: {}
@@ -5365,6 +5383,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-standalone-pwa@0.1.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -6359,6 +6379,14 @@ snapshots:
typescript@5.9.3: {}
ua-is-frozen@0.1.2: {}
ua-parser-js@2.0.10:
dependencies:
detect-europe-js: 0.1.2
is-standalone-pwa: 0.1.1
ua-is-frozen: 0.1.2
ufo@1.6.4: {}
unbox-primitive@1.1.0:
+133 -17
View File
@@ -1,26 +1,142 @@
import { UAParser } from "ua-parser-js";
export type InAppBrowserName =
| "facebook"
| "instagram"
| "wechat"
| "weibo"
| "qq"
| "line"
| "tiktok"
| "webview"
| "unknown";
export interface BrowserInfo {
name: string;
version: string;
major: string;
engineName: string;
engineVersion: string;
isInAppBrowser: boolean;
inAppBrowserName: InAppBrowserName | null;
userAgent: string;
}
/**
* / WebView
*
* OS / / 使 `PlatformDetector`
*/
export const BrowserDetector = {
export class BrowserDetector {
private constructor() {}
static getBrowserInfo(ua: string | null = null): BrowserInfo {
const userAgent = BrowserDetector.getUserAgent(ua);
const result = new UAParser(userAgent).getResult();
const inAppBrowserName = BrowserDetector.getInAppBrowserName(userAgent);
return {
name: result.browser.name ?? "",
version: result.browser.version ?? "",
major: result.browser.major ?? "",
engineName: result.engine.name ?? "",
engineVersion: result.engine.version ?? "",
isInAppBrowser: inAppBrowserName !== null,
inAppBrowserName,
userAgent,
};
}
static getBrowserName(ua: string | null = null): string {
return BrowserDetector.getBrowserInfo(ua).name;
}
static getBrowserVersion(ua: string | null = null): string {
return BrowserDetector.getBrowserInfo(ua).version;
}
static isChrome(ua: string | null = null): boolean {
return /Chrome|CriOS/i.test(BrowserDetector.getBrowserName(ua));
}
static isSafari(ua: string | null = null): boolean {
return /Safari/i.test(BrowserDetector.getBrowserName(ua));
}
static isEdge(ua: string | null = null): boolean {
return /Edge/i.test(BrowserDetector.getBrowserName(ua));
}
/** 当前是否在 Facebook 内嵌浏览器 */
isFacebookInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /FBAN|FBAV|Instagram/i.test(userAgent);
},
static isFacebookInAppBrowser(ua: string | null = null): boolean {
return /FBAN|FBAV|FB_IAB|FB4A|FBIOS/i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在 Instagram 内嵌浏览器 */
static isInstagramInAppBrowser(ua: string | null = null): boolean {
return /Instagram/i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在微信内嵌浏览器 */
isWeChatInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /MicroMessenger/i.test(userAgent);
},
static isWeChatInAppBrowser(ua: string | null = null): boolean {
return /MicroMessenger/i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在微博内嵌浏览器 */
static isWeiboInAppBrowser(ua: string | null = null): boolean {
return /Weibo/i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在 QQ 内嵌浏览器 */
static isQQInAppBrowser(ua: string | null = null): boolean {
return /QQ\//i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在 Line 内嵌浏览器 */
static isLineInAppBrowser(ua: string | null = null): boolean {
return /Line\//i.test(BrowserDetector.getUserAgent(ua));
}
/** 当前是否在 TikTok 内嵌浏览器 */
static isTikTokInAppBrowser(ua: string | null = null): boolean {
return /BytedanceWebview|TikTok|Musical_ly/i.test(
BrowserDetector.getUserAgent(ua),
);
}
/** 当前是否在 WebView(不通用;仅作 best-effort 判断) */
isInAppBrowser(ua: string | null = null): boolean {
if (typeof navigator === "undefined" && !ua) return false;
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
return /WebView|; wv\)|FBAN|FBAV|MicroMessenger|Instagram|Line\//i.test(userAgent);
},
static isWebView(ua: string | null = null): boolean {
const userAgent = BrowserDetector.getUserAgent(ua);
return (
/WebView|; wv\)/i.test(userAgent) ||
(/iPhone|iPad|iPod/i.test(userAgent) &&
/AppleWebKit/i.test(userAgent) &&
!/Safari/i.test(userAgent))
);
}
};
/** 当前是否在应用内浏览器 */
static isInAppBrowser(ua: string | null = null): boolean {
return BrowserDetector.getInAppBrowserName(ua) !== null;
}
static getInAppBrowserName(
ua: string | null = null,
): InAppBrowserName | null {
if (BrowserDetector.isFacebookInAppBrowser(ua)) return "facebook";
if (BrowserDetector.isInstagramInAppBrowser(ua)) return "instagram";
if (BrowserDetector.isWeChatInAppBrowser(ua)) return "wechat";
if (BrowserDetector.isWeiboInAppBrowser(ua)) return "weibo";
if (BrowserDetector.isQQInAppBrowser(ua)) return "qq";
if (BrowserDetector.isLineInAppBrowser(ua)) return "line";
if (BrowserDetector.isTikTokInAppBrowser(ua)) return "tiktok";
if (BrowserDetector.isWebView(ua)) return "webview";
return null;
}
private static getUserAgent(ua: string | null = null): string {
if (ua) return ua;
if (typeof navigator === "undefined") return "";
return navigator.userAgent;
}
}
+98
View File
@@ -0,0 +1,98 @@
import { UAParser } from "ua-parser-js";
export type PlatformName = "ios" | "android" | "desktop" | "unknown";
export interface PlatformInfo {
platform: PlatformName;
osName: string;
osVersion: string;
deviceType: string;
deviceVendor: string;
deviceModel: string;
cpuArchitecture: string;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isIOS: boolean;
isAndroid: boolean;
userAgent: string;
}
/**
* / OS /
*
* 使 `BrowserDetector`
*/
export class PlatformDetector {
private constructor() {}
static getPlatformInfo(ua: string | null = null): PlatformInfo {
const userAgent = PlatformDetector.getUserAgent(ua);
const result = new UAParser(userAgent).getResult();
const osName = result.os.name ?? "";
const deviceType = result.device.type ?? "";
const isIOS = PlatformDetector.isIOS(userAgent);
const isAndroid = PlatformDetector.isAndroid(userAgent);
const isTablet = deviceType === "tablet" || PlatformDetector.isIPadOS();
const isMobile = deviceType === "mobile" || isTablet;
return {
platform: PlatformDetector.getPlatform(userAgent),
osName,
osVersion: result.os.version ?? "",
deviceType,
deviceVendor: result.device.vendor ?? "",
deviceModel: result.device.model ?? "",
cpuArchitecture: result.cpu.architecture ?? "",
isMobile,
isTablet,
isDesktop: !isMobile,
isIOS,
isAndroid,
userAgent,
};
}
static getPlatform(ua: string | null = null): PlatformName {
const userAgent = PlatformDetector.getUserAgent(ua);
if (PlatformDetector.isIOS(userAgent)) return "ios";
if (PlatformDetector.isAndroid(userAgent)) return "android";
if (userAgent.length > 0) return "desktop";
return "unknown";
}
static isIOS(ua: string | null = null): boolean {
const userAgent = PlatformDetector.getUserAgent(ua);
const osName = new UAParser(userAgent).getOS().name ?? "";
return /iOS/i.test(osName) || PlatformDetector.isIPadOS();
}
static isAndroid(ua: string | null = null): boolean {
const userAgent = PlatformDetector.getUserAgent(ua);
const osName = new UAParser(userAgent).getOS().name ?? "";
return /Android/i.test(osName) || /Android/i.test(userAgent);
}
static isMobile(ua: string | null = null): boolean {
return PlatformDetector.getPlatformInfo(ua).isMobile;
}
static isTablet(ua: string | null = null): boolean {
return PlatformDetector.getPlatformInfo(ua).isTablet;
}
static isDesktop(ua: string | null = null): boolean {
return PlatformDetector.getPlatformInfo(ua).isDesktop;
}
private static getUserAgent(ua: string | null = null): string {
if (ua) return ua;
if (typeof navigator === "undefined") return "";
return navigator.userAgent;
}
private static isIPadOS(): boolean {
if (typeof navigator === "undefined") return false;
return navigator.platform === "MacIntel" && (navigator.maxTouchPoints ?? 0) > 1;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { PlatformDetector } from "./platform-detect";
/**
* URL
*
* Flutter `lib/utils/url_launcher_util.dart`
* - Android: 使用 Chrome Intent URL
* - iOS: 使用 Safari URL scheme
* - 其他平台: 使用普通新窗口打开退
*/
export type UrlQueryParams = Record<string, string>;
export class UrlLauncherUtil {
private constructor() {}
static openUrlWithExternalBrowser(
url: string,
options: { queryParams?: UrlQueryParams } = {},
): void {
if (typeof window === "undefined") return;
const finalUrl = UrlLauncherUtil.appendQueryParams(
UrlLauncherUtil.toAbsoluteUrl(url),
options.queryParams,
);
if (PlatformDetector.isAndroid()) {
window.location.href = UrlLauncherUtil.buildIntentUrl(finalUrl);
return;
}
if (PlatformDetector.isIOS()) {
window.location.href = UrlLauncherUtil.buildSafariUrl(finalUrl);
return;
}
UrlLauncherUtil.openUrl(finalUrl);
}
static openUrl(url: string): void {
if (typeof window === "undefined") return;
const finalUrl = UrlLauncherUtil.toAbsoluteUrl(url);
const opened = window.open(finalUrl, "_blank", "noopener,noreferrer");
if (!opened) {
window.location.href = finalUrl;
}
}
static appendQueryParams(
url: string,
queryParams?: UrlQueryParams,
): string {
if (!queryParams || Object.keys(queryParams).length === 0) return url;
const uri = new URL(url, UrlLauncherUtil.getBaseUrl());
for (const [key, value] of Object.entries(queryParams)) {
uri.searchParams.set(key, value);
}
return uri.toString();
}
static buildIntentUrl(url: string): string {
const uri = new URL(url, UrlLauncherUtil.getBaseUrl());
const scheme = uri.protocol.replace(":", "") || "https";
const path = `${uri.pathname}${uri.search}`;
return `intent://${uri.host}${path}#Intent;scheme=${scheme};package=com.android.chrome;end`;
}
static buildSafariUrl(url: string): string {
const uri = new URL(url, UrlLauncherUtil.getBaseUrl());
const path = `${uri.pathname}${uri.search}`;
return `x-safari-https://${uri.host}${path}`;
}
private static toAbsoluteUrl(url: string): string {
return new URL(url, UrlLauncherUtil.getBaseUrl()).toString();
}
private static getBaseUrl(): string {
if (typeof window !== "undefined") return window.location.origin;
return "https://cozsweet.com";
}
}