mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
105 lines
4.1 KiB
TypeScript
105 lines
4.1 KiB
TypeScript
/**
|
|
* Anonymous install telemetry shared by the DSH Web UI family plugins.
|
|
*
|
|
* Once per UTC day per browser, each wired plugin sends one heartbeat to
|
|
* the dsh-market edge API listing its package name (and version when known).
|
|
* The payload carries no conversation data and no identifiers beyond a random
|
|
* UUID generated in localStorage; the server hashes it with a deployment salt
|
|
* before storage and never persists IP addresses. Sends are fire-and-forget:
|
|
* failures stay silent and simply retry on a later mount or day. The system
|
|
* is documented in docs/telemetry.md.
|
|
*/
|
|
|
|
export type TelemetryChannel = 'market' | 'npm' | 'unknown'
|
|
|
|
export interface TelemetryItem {
|
|
/** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
|
|
name: string
|
|
/** Installed version when known; omitted otherwise. */
|
|
version?: string
|
|
/** Install channel when determinable (market = Workshop install). */
|
|
channel?: TelemetryChannel
|
|
}
|
|
|
|
const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
|
|
const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
|
|
const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
|
|
|
|
// Baked into each client bundle by shared/tsdown.client.ts (the package's own
|
|
// package.json version at build time). Undefined in dev/test builds.
|
|
declare const __DSH_PKG_VERSION__: string | undefined
|
|
|
|
/** The building package's version, when the bundle carries it. */
|
|
function bakedVersion(): string | undefined {
|
|
try {
|
|
return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
|
|
? __DSH_PKG_VERSION__
|
|
: undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
|
|
function visitorId(): string | null {
|
|
try {
|
|
const existing = localStorage.getItem(VISITOR_KEY)
|
|
if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
|
|
const fresh = crypto.randomUUID().replaceAll('-', '')
|
|
localStorage.setItem(VISITOR_KEY, fresh)
|
|
return fresh
|
|
} catch {
|
|
// Storage unavailable (privacy mode, sandboxed frame): report nothing.
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Drop stale per-day dedup keys so localStorage does not grow forever. */
|
|
function pruneDayKeys(today: string): void {
|
|
try {
|
|
for (let index = localStorage.length - 1; index >= 0; index -= 1) {
|
|
const key = localStorage.key(index)
|
|
if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
|
|
localStorage.removeItem(key)
|
|
}
|
|
}
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
/**
|
|
* Fire the daily heartbeat for the given items at most once per UTC day per
|
|
* browser. Never throws and never blocks the caller. Items without an explicit
|
|
* version inherit the bundle's baked build version.
|
|
*/
|
|
export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
|
|
try {
|
|
if (items.length === 0) return
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
// Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
|
|
// mint a new visitor id per run and would inflate instance counts.
|
|
if (navigator.webdriver) return
|
|
if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
|
|
const visitor = visitorId()
|
|
if (visitor === null) return
|
|
pruneDayKeys(today)
|
|
const payloadItems = items.map((item) => {
|
|
const out: Record<string, string> = { name: item.name }
|
|
const version = item.version ?? bakedVersion()
|
|
if (version !== undefined) out.version = version
|
|
if (item.channel !== undefined) out.channel = item.channel
|
|
return out
|
|
})
|
|
const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
|
|
void fetch(ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body,
|
|
keepalive: true,
|
|
}).then((response) => {
|
|
// Mark the day only after an accepted send, so offline browsers retry
|
|
// on a later mount instead of going dark until tomorrow.
|
|
if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
|
|
}).catch(() => { /* unreachable endpoint: stay silent */ })
|
|
} catch { /* never break the host UI over telemetry */ }
|
|
}
|