95 lines
4.8 KiB
TypeScript
95 lines
4.8 KiB
TypeScript
import type { ItemEvent } from '@webdrop/core';
|
|
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
|
import { previewKindOf } from '@/lib/fileKind';
|
|
import { publish } from './port';
|
|
interface ClientHints {
|
|
mobile?: boolean;
|
|
model?: string;
|
|
platform?: string;
|
|
platformVersion?: string;
|
|
getHighEntropyValues?: (hints: string[]) => Promise<ClientHints>;
|
|
}
|
|
|
|
let clientHintsPromise: Promise<ClientHints> | undefined;
|
|
|
|
async function readClientHints(): Promise<ClientHints> {
|
|
const hints = (navigator as Navigator & { userAgentData?: ClientHints }).userAgentData;
|
|
if (!hints?.getHighEntropyValues) return hints ?? {};
|
|
clientHintsPromise ??= hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints);
|
|
return clientHintsPromise;
|
|
}
|
|
|
|
function clean(value: string | undefined): string | undefined {
|
|
const result = value?.replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 48);
|
|
return result || undefined;
|
|
}
|
|
|
|
function version(value: string | undefined): string | undefined {
|
|
return value?.split('.').slice(0, 2).join('.') || undefined;
|
|
}
|
|
|
|
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
|
|
const ua = navigator.userAgent;
|
|
const hints = await readClientHints();
|
|
const android = /Android/i.test(ua) || hints.platform === 'Android';
|
|
const iphone = /iPhone|iPod/i.test(ua);
|
|
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
|
|
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
|
|
const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
|
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
|
const windowsVersion = version(hints.platformVersion);
|
|
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
|
const androidVersion = ua.match(/Android ([\d.]+)/i)?.[1];
|
|
const platformVersion = version(hints.platformVersion);
|
|
let identity: string;
|
|
if (clean(hints.model)) {
|
|
identity = `${clean(hints.model)} (${clean(hints.platform) ?? 'Android'}${platformVersion ? ` ${platformVersion}` : ''})`;
|
|
} else if (iphone) {
|
|
identity = `iPhone${iosVersion ? ` (iOS ${iosVersion})` : ''}`;
|
|
} else if (ipad) {
|
|
identity = `iPad${iosVersion ? ` (iPadOS ${iosVersion})` : ''}`;
|
|
} else if (/Windows/i.test(ua) || hints.platform === 'Windows') {
|
|
identity = `Windows${windowsVersion ? ` ${Number(windowsVersion.split('.')[0]) >= 13 ? '11' : '10'}` : ''}`;
|
|
} else if (android) {
|
|
identity = `Android${platformVersion ?? androidVersion ? ` ${platformVersion ?? androidVersion}` : ''}`;
|
|
} else if (/Macintosh|Mac OS X/i.test(ua) || hints.platform === 'macOS') {
|
|
identity = `Mac${platformVersion ?? macVersion ? ` (macOS ${platformVersion ?? macVersion})` : ''}`;
|
|
} else if (/CrOS/i.test(ua) || hints.platform === 'Chrome OS') {
|
|
identity = 'ChromeOS';
|
|
} else if (/Linux/i.test(ua) || hints.platform === 'Linux') {
|
|
identity = 'Linux';
|
|
} else {
|
|
identity = '未知设备';
|
|
}
|
|
const suffix = Array.from(endpointSecret.subarray(0, 4), (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
return { name: `${identity} · ${suffix}`, kind };
|
|
}
|
|
|
|
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
|
|
const name = label ?? '未知设备';
|
|
const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
|
|
: /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
|
|
: /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
|
|
: 'desktop';
|
|
return { id, name, kind, self: id === selfId, joinedAt };
|
|
}
|
|
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
|
|
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
|
|
else if (event.itemId) publish(resourceId, {
|
|
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
|
|
});
|
|
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
|
|
}
|
|
|
|
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
|
|
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
|
}
|
|
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
|
|
publish(resourceId, event);
|
|
}
|
|
|
|
export function publishShare(resourceId: string, event: ShareEvent): void {
|
|
publish(resourceId, event);
|
|
}
|