diff --git a/apps/mobile/src/player/controller.ts b/apps/mobile/src/player/controller.ts index 33beb44..510beb8 100644 --- a/apps/mobile/src/player/controller.ts +++ b/apps/mobile/src/player/controller.ts @@ -20,6 +20,7 @@ import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { downloads } from '../offline/downloads'; import { PersistentEvents } from '../offline/events'; import { offlineWave } from './offlineWave'; +import { eventId } from './eventId'; export { offlineWave }; @@ -113,7 +114,7 @@ class Controller { this.server = server; this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null; const sink = this.persistent; - this.events = sink ? new EventBuffer({ send: sink.send }) : null; + this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null; void sink?.drain(); this.unsubscribeNet ??= NetInfo.addEventListener((st) => { if (st.isConnected) void this.persistent?.drain(); diff --git a/apps/mobile/src/player/eventId.ts b/apps/mobile/src/player/eventId.ts new file mode 100644 index 0000000..5cbfb65 --- /dev/null +++ b/apps/mobile/src/player/eventId.ts @@ -0,0 +1,9 @@ +import { uuidv7 } from '@pipu/core'; + +/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */ +export function eventId(now: number): string { + return uuidv7(now, () => { + const hex = globalThis.expo.uuidv4().replace(/-/g, ''); + return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16)); + }); +} diff --git a/apps/mobile/test/eventId.test.ts b/apps/mobile/test/eventId.test.ts new file mode 100644 index 0000000..71245e2 --- /dev/null +++ b/apps/mobile/test/eventId.test.ts @@ -0,0 +1,19 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { EventBuffer } from '@pipu/core'; +import { eventId } from '../src/player/eventId'; + +afterEach(() => vi.unstubAllGlobals()); + +it('queues restored-track events on Hermes without Web Crypto', () => { + vi.stubGlobal('crypto', undefined); + const nativeUuid = vi.fn() + .mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff') + .mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe'); + vi.stubGlobal('expo', { uuidv4: nativeUuid }); + const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId); + buffer.push({ type: 'play_start', track_id: 'restored-track' }); + buffer.push({ type: 'skip', track_id: 'restored-track' }); + expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff'); + expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id); + expect(nativeUuid).toHaveBeenCalledTimes(2); +}); diff --git a/docs/mobile.md b/docs/mobile.md index 34e75f2..be4fe67 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -29,6 +29,10 @@ - Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect` выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`. Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`. +- События прослушивания используют UUIDv7 со случайными байтами из нативного `expo.uuidv4()`. + Web Crypto (`crypto.getRandomValues`) в Hermes отсутствует: браузерный генератор ронял приложение + при восстановлении непустой очереди с компьютера, когда TrackPlayer сообщал о выбранном треке. + Генератор передаётся в общий `EventBuffer`; веб продолжает использовать Web Crypto. ## Экраны diff --git a/packages/core/src/player.ts b/packages/core/src/player.ts index c341556..a8ae6b0 100644 --- a/packages/core/src/player.ts +++ b/packages/core/src/player.ts @@ -188,6 +188,7 @@ export class EventBuffer { private readonly sink: EventSink, private readonly now: () => number = Date.now, private readonly maxRetryMs = 5 * 60_000, + private readonly createId: (now: number) => string = uuidv7, ) {} get pending(): readonly PlayEvent[] { @@ -196,7 +197,7 @@ export class EventBuffer { push(e: Omit & Partial>) { const t = this.now(); - this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent); + this.queue.push({ id: e.id ?? this.createId(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent); if (this.queue.length >= 500) void this.flush(); }