generated from VLADIMIR/template_frontend
up
This commit is contained in:
+65
-8
@@ -41,6 +41,23 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
|
||||
};
|
||||
|
||||
service EveningDetectiveServer {
|
||||
rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) {
|
||||
option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" };
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Устройства";
|
||||
summary: "Обновить присутствие подключения браузера";
|
||||
description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.";
|
||||
security: {};
|
||||
};
|
||||
}
|
||||
rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) {
|
||||
option (google.api.http) = { get: "/api/devices" };
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Устройства";
|
||||
summary: "Получить устройства площадки";
|
||||
description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.";
|
||||
};
|
||||
}
|
||||
rpc GetTasks(GetTasksReq) returns (GetTasksRsp) {
|
||||
option (google.api.http) = { get: "/api/tasks" };
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
@@ -638,8 +655,8 @@ service EveningDetectiveServer {
|
||||
rpc DownloadGameResultsPackage(GameResultsPackageReq) returns (google.api.HttpBody) {
|
||||
option (google.api.http) = {get: "/api/games/{ID}/results-package/download"};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Игры";
|
||||
summary: "Скачать свежий ZIP результатов завершённой venue-игры";
|
||||
tags : "Игры";
|
||||
summary : "Скачать свежий ZIP результатов завершённой venue-игры";
|
||||
produces: "application/zip";
|
||||
};
|
||||
}
|
||||
@@ -650,11 +667,11 @@ service EveningDetectiveServer {
|
||||
body: "package"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Игры";
|
||||
summary: "Применить ZIP результатов на исходной prod-игре";
|
||||
tags : "Игры";
|
||||
summary : "Применить ZIP результатов на исходной prod-игре";
|
||||
description: "HTTP принимает сырой ZIP до 1 ГиБ, включая поток без Content-Length. Ошибки: 400 неверный пакет/игра, 413 размер, 409 конфликт, 503 временный отказ.";
|
||||
consumes: "application/zip";
|
||||
produces: "application/json";
|
||||
consumes : "application/zip";
|
||||
produces : "application/json";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2371,12 +2388,52 @@ message TaskRsp { DeveloperTask task = 1; }
|
||||
message DeleteTaskRsp {}
|
||||
|
||||
message GameResultsPackageReq { int32 ID = 1; }
|
||||
|
||||
message ApplyGameResultsPackageReq {
|
||||
int32 ID = 1;
|
||||
int32 ID = 1;
|
||||
google.api.HttpBody package = 2;
|
||||
}
|
||||
|
||||
message GameResultsPackageRsp {
|
||||
int32 gameID = 1;
|
||||
int32 gameID = 1;
|
||||
string packageID = 2;
|
||||
string appliedAt = 3;
|
||||
}
|
||||
|
||||
message DeviceHeartbeatReq {
|
||||
string deviceID = 1;
|
||||
string connectionID = 2;
|
||||
optional int32 organizerGameID = 3;
|
||||
optional int32 teamID = 4;
|
||||
string password = 5;
|
||||
string screenID = 6;
|
||||
string accessToken = 7;
|
||||
}
|
||||
message DeviceHeartbeatRsp {
|
||||
optional bool enabled = 1;
|
||||
google.protobuf.Timestamp serverTime = 2;
|
||||
}
|
||||
message GetDevicesReq { optional int32 gameID = 1; }
|
||||
message GetDevicesRsp {
|
||||
repeated VenueDevice devices = 1;
|
||||
google.protobuf.Timestamp serverTime = 2;
|
||||
}
|
||||
message VenueDevice {
|
||||
string ID = 1;
|
||||
google.protobuf.Timestamp firstSeenAt = 2;
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
bool connected = 4;
|
||||
repeated DeviceConnection connections = 5;
|
||||
}
|
||||
message DeviceConnection {
|
||||
string ID = 1;
|
||||
google.protobuf.Timestamp firstSeenAt = 2;
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
string IP = 4;
|
||||
string userAgent = 5;
|
||||
bool connected = 6;
|
||||
optional int32 gameID = 7;
|
||||
optional int32 teamID = 8;
|
||||
optional int32 screenID = 9;
|
||||
string kind = 10; // browser, organizer, team, screen
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import { useDevicePresence } from '@/composables/useDevicePresence'
|
||||
|
||||
useDevicePresence()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1281,7 +1281,54 @@ export type GameResultsPackageRsp = {
|
||||
appliedAt: string | undefined;
|
||||
};
|
||||
|
||||
export type DeviceHeartbeatReq = {
|
||||
deviceID: string | undefined;
|
||||
connectionID: string | undefined;
|
||||
organizerGameID?: number;
|
||||
teamID?: number;
|
||||
password: string | undefined;
|
||||
screenID: string | undefined;
|
||||
accessToken: string | undefined;
|
||||
};
|
||||
|
||||
export type DeviceHeartbeatRsp = {
|
||||
enabled?: boolean;
|
||||
serverTime: wellKnownTimestamp | undefined;
|
||||
};
|
||||
|
||||
export type GetDevicesReq = {
|
||||
gameID?: number;
|
||||
};
|
||||
|
||||
export type GetDevicesRsp = {
|
||||
devices: VenueDevice[] | undefined;
|
||||
serverTime: wellKnownTimestamp | undefined;
|
||||
};
|
||||
|
||||
export type VenueDevice = {
|
||||
ID: string | undefined;
|
||||
firstSeenAt: wellKnownTimestamp | undefined;
|
||||
lastSeenAt: wellKnownTimestamp | undefined;
|
||||
connected: boolean | undefined;
|
||||
connections: DeviceConnection[] | undefined;
|
||||
};
|
||||
|
||||
export type DeviceConnection = {
|
||||
ID: string | undefined;
|
||||
firstSeenAt: wellKnownTimestamp | undefined;
|
||||
lastSeenAt: wellKnownTimestamp | undefined;
|
||||
IP: string | undefined;
|
||||
userAgent: string | undefined;
|
||||
connected: boolean | undefined;
|
||||
gameID?: number;
|
||||
teamID?: number;
|
||||
screenID?: number;
|
||||
kind: string | undefined;
|
||||
};
|
||||
|
||||
export interface EveningDetectiveServer {
|
||||
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
|
||||
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
|
||||
GetTasks(request: GetTasksReq): Promise<GetTasksRsp>;
|
||||
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
|
||||
CreateTask(request: CreateTaskReq): Promise<TaskRsp>;
|
||||
@@ -1417,6 +1464,43 @@ export function createEveningDetectiveServerClient(
|
||||
handler: RequestHandler
|
||||
): EveningDetectiveServer {
|
||||
return {
|
||||
DeviceHeartbeat(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/devices/heartbeat`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "DeviceHeartbeat",
|
||||
}) as Promise<DeviceHeartbeatRsp>;
|
||||
},
|
||||
GetDevices(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/devices`; // eslint-disable-line quotes
|
||||
const body = null;
|
||||
const queryParams: string[] = [];
|
||||
if (request.gameID) {
|
||||
queryParams.push(`gameID=${encodeURIComponent(request.gameID.toString())}`)
|
||||
}
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "GET",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "GetDevices",
|
||||
}) as Promise<GetDevicesRsp>;
|
||||
},
|
||||
GetTasks(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/tasks`; // eslint-disable-line quotes
|
||||
const body = null;
|
||||
|
||||
@@ -50,6 +50,7 @@ import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
@@ -64,6 +65,7 @@ const isDownloadingQR = ref(false)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const gameId = route.params.id
|
||||
useDeviceContext(() => ({ organizerGameID: Number(gameId) }))
|
||||
type GameTab = 'settings' | 'teams' | 'screens' | 'game' | 'payment' | 'answers'
|
||||
|
||||
function isGameTab(value: string): value is GameTab {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Game, ScreenState, TeamResult } from '@/api/generated/crabs/evening_detective_server'
|
||||
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||
import { applicationFileURL } from '@/utils/application'
|
||||
import { formatGameDateTime } from '@/utils/datetime'
|
||||
@@ -44,6 +45,9 @@ let appliedCommandVersion = 0
|
||||
let timerTick: number | null = null
|
||||
|
||||
const bindingFlow = computed(() => Boolean(route.query.screenID && route.query.accessToken))
|
||||
useDeviceContext(() => (screenId.value && accessToken.value && !bindingFlow.value
|
||||
? { screenID: screenId.value, accessToken: accessToken.value }
|
||||
: {}))
|
||||
const qrValue = computed(() => {
|
||||
if (!screenId.value || !accessToken.value) return ''
|
||||
const url = new URL('/screen', window.location.origin)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import GameInputForm from '@/components/GameInputForm.vue'
|
||||
import IntroBlock from '@/components/IntroBlock.vue'
|
||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -26,6 +27,7 @@ const message = useMessage()
|
||||
const route = useRoute()
|
||||
const teamId = route.params.id
|
||||
const password = route.query.password
|
||||
useDeviceContext(() => ({ teamID: Number(teamId), password: typeof password === 'string' ? password : undefined }))
|
||||
|
||||
const newPlacesCount = ref(0)
|
||||
const actions = ref<TeamAction[]>([])
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
import { createServer } from 'vite'
|
||||
import { createRenderer, nextTick } from 'vue'
|
||||
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL('../../..', import.meta.url)),
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } },
|
||||
server: { middlewareMode: true, hmr: false, ws: false },
|
||||
})
|
||||
const { useDevicePresence } = await server.ssrLoadModule('/src/composables/useDevicePresence.ts')
|
||||
const { useDevicePresenceStore } = await server.ssrLoadModule('/src/stores/devicePresence.ts')
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalWarn = console.warn
|
||||
const originalClearInterval = globalThis.clearInterval
|
||||
const descriptors = ['window', 'document', 'localStorage'].map((key) => [
|
||||
key, Object.getOwnPropertyDescriptor(globalThis, key),
|
||||
] as const)
|
||||
after(async () => {
|
||||
globalThis.fetch = originalFetch
|
||||
globalThis.clearInterval = originalClearInterval
|
||||
console.warn = originalWarn
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor)
|
||||
else Reflect.deleteProperty(globalThis, key)
|
||||
}
|
||||
await server.close()
|
||||
})
|
||||
|
||||
const renderer = createRenderer({
|
||||
patchProp() {}, insert() {}, remove() {},
|
||||
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
|
||||
setText() {}, setElementText() {}, parentNode: () => null, nextSibling: () => null,
|
||||
})
|
||||
const settle = async () => { await nextTick(); await new Promise(setImmediate) }
|
||||
|
||||
function mount() {
|
||||
const callbacks = new Map<number, () => void>()
|
||||
const windowEvents = Object.assign(new EventTarget(), {
|
||||
setInterval(callback: () => void) { callbacks.set(1, callback); return 1 },
|
||||
})
|
||||
const documentEvents = Object.assign(new EventTarget(), { visibilityState: 'visible' })
|
||||
const storage = new Map<string, string>([['accessToken', 'expired'], ['refreshToken', 'expired-refresh']])
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: windowEvents })
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentEvents })
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
} })
|
||||
globalThis.clearInterval = ((id: number) => callbacks.delete(id)) as typeof clearInterval
|
||||
console.warn = () => {}
|
||||
const pinia = createPinia()
|
||||
const app = renderer.createApp({ setup() { useDevicePresence(); return () => null } })
|
||||
app.use(pinia)
|
||||
app.mount({})
|
||||
return { app, callbacks, windowEvents, documentEvents, storage, store: useDevicePresenceStore(pinia) }
|
||||
}
|
||||
|
||||
function deferredResponse() {
|
||||
let resolve!: (response: Response) => void
|
||||
const promise = new Promise<Response>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const json = (enabled: boolean) => new Response(JSON.stringify({ enabled }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
test('requests never overlap; latest context follows in-flight request; disabled stops polling', async () => {
|
||||
const first = deferredResponse()
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
requests.push(JSON.parse(String(options?.body)))
|
||||
return requests.length === 1 ? first.promise : json(true)
|
||||
}
|
||||
const fixture = mount()
|
||||
try {
|
||||
assert.equal(requests.length, 1)
|
||||
fixture.windowEvents.dispatchEvent(new Event('online'))
|
||||
fixture.documentEvents.dispatchEvent(new Event('visibilitychange'))
|
||||
fixture.callbacks.get(1)!()
|
||||
const lease = fixture.store.claim()
|
||||
lease.set({ teamID: 2, password: 'secret' })
|
||||
await settle()
|
||||
assert.equal(requests.length, 1)
|
||||
first.resolve(json(true))
|
||||
await settle()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].teamID, 2)
|
||||
assert.equal(requests[0].deviceID, requests[1].deviceID)
|
||||
assert.equal(requests[0].connectionID, requests[1].connectionID)
|
||||
globalThis.fetch = async () => json(false)
|
||||
fixture.windowEvents.dispatchEvent(new Event('online'))
|
||||
await settle()
|
||||
assert.equal(fixture.callbacks.size, 0)
|
||||
const stoppedAttempts = requests.length
|
||||
globalThis.fetch = async () => { requests.push({}); return json(true) }
|
||||
lease.clear()
|
||||
fixture.windowEvents.dispatchEvent(new Event('online'))
|
||||
await settle()
|
||||
assert.equal(requests.length, stoppedAttempts)
|
||||
} finally { fixture.app.unmount() }
|
||||
})
|
||||
|
||||
test('public credential failure does not refresh or clear JWT and waits for next interval', async () => {
|
||||
let attempts = 0
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /devices\/heartbeat$/)
|
||||
attempts++
|
||||
return new Response('{}', { status: 401 })
|
||||
}
|
||||
const fixture = mount()
|
||||
try {
|
||||
await settle()
|
||||
fixture.store.claim().set({ screenID: '3', accessToken: 'bad' })
|
||||
await settle()
|
||||
assert.equal(attempts, 2)
|
||||
assert.equal(fixture.storage.get('accessToken'), 'expired')
|
||||
assert.equal(fixture.storage.get('refreshToken'), 'expired-refresh')
|
||||
fixture.callbacks.get(1)!()
|
||||
await settle()
|
||||
assert.equal(attempts, 3)
|
||||
} finally { fixture.app.unmount() }
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { type DeviceContext, useDevicePresenceStore } from '@/stores/devicePresence'
|
||||
|
||||
export function useDeviceContext(source: () => Partial<DeviceContext>) {
|
||||
const lease = useDevicePresenceStore().claim()
|
||||
watch(source, lease.set, { immediate: true })
|
||||
onScopeDispose(lease.clear)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||
import { useDevicePresenceStore } from '@/stores/devicePresence'
|
||||
import { browserDeviceID, newConnectionID } from '@/utils/deviceID'
|
||||
|
||||
export function useDevicePresence() {
|
||||
const authClient = getAuthClient()
|
||||
const publicClient = buildClient()
|
||||
const store = useDevicePresenceStore()
|
||||
const deviceID = browserDeviceID()
|
||||
const connectionID = newConnectionID()
|
||||
let isEnabled = true
|
||||
let isSending = false
|
||||
let isDisposed = false
|
||||
let shouldSendNext = false
|
||||
|
||||
async function heartbeat() {
|
||||
if (!isEnabled || isDisposed) return
|
||||
if (isSending) {
|
||||
shouldSendNext = true
|
||||
return
|
||||
}
|
||||
isSending = true
|
||||
shouldSendNext = false
|
||||
try {
|
||||
const client = store.context.organizerGameID ? authClient : publicClient
|
||||
const response = await client.DeviceHeartbeat({
|
||||
deviceID,
|
||||
connectionID,
|
||||
organizerGameID: store.context.organizerGameID,
|
||||
teamID: store.context.teamID,
|
||||
password: store.context.password,
|
||||
screenID: store.context.screenID,
|
||||
accessToken: store.context.accessToken,
|
||||
})
|
||||
if (response.enabled === false) {
|
||||
isEnabled = false
|
||||
polling.stop()
|
||||
}
|
||||
} catch {
|
||||
console.warn('Не удалось обновить присутствие устройства; повтор по интервалу')
|
||||
shouldSendNext = false
|
||||
} finally {
|
||||
isSending = false
|
||||
if (shouldSendNext) void heartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
function visible() {
|
||||
if (document.visibilityState === 'visible') void heartbeat()
|
||||
}
|
||||
|
||||
const polling = useSimplePolling(() => void heartbeat(), 20_000)
|
||||
watch(() => store.context, () => void heartbeat())
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', visible)
|
||||
window.addEventListener('online', heartbeat)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
isDisposed = true
|
||||
document.removeEventListener('visibilitychange', visible)
|
||||
window.removeEventListener('online', heartbeat)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { useDevicePresenceStore } from '../devicePresence.ts'
|
||||
|
||||
test('page leases clear context and reject late updates after navigation', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useDevicePresenceStore()
|
||||
const player = store.claim()
|
||||
player.set({ teamID: 2, password: 'secret' })
|
||||
assert.equal(store.context.teamID, 2)
|
||||
const screen = store.claim()
|
||||
screen.set({ screenID: '3', accessToken: 'token' })
|
||||
player.clear()
|
||||
player.set({ teamID: 2, password: 'stale' })
|
||||
assert.deepEqual(store.context, { screenID: '3', accessToken: 'token' })
|
||||
screen.clear()
|
||||
screen.set({ screenID: '3', accessToken: 'stale' })
|
||||
assert.deepEqual(store.context, {})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import type { DeviceHeartbeatReq } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
export type DeviceContext = Pick<
|
||||
DeviceHeartbeatReq,
|
||||
'organizerGameID' | 'teamID' | 'password' | 'screenID' | 'accessToken'
|
||||
>
|
||||
|
||||
export const useDevicePresenceStore = defineStore('devicePresence', () => {
|
||||
const context = shallowRef<Partial<DeviceContext>>({})
|
||||
let owner: symbol | undefined
|
||||
|
||||
function claim() {
|
||||
const id = Symbol()
|
||||
owner = id
|
||||
context.value = {}
|
||||
return {
|
||||
set(value: Partial<DeviceContext>) {
|
||||
if (owner === id) context.value = value
|
||||
},
|
||||
clear() {
|
||||
if (owner !== id) return
|
||||
owner = undefined
|
||||
context.value = {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { context, claim }
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, test } from 'node:test'
|
||||
|
||||
import { browserDeviceID, newConnectionID } from '../deviceID.ts'
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
||||
after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'localStorage', original)
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage')
|
||||
})
|
||||
|
||||
test('device survives reload, connections differ and malformed storage is replaced', () => {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
})
|
||||
const device = browserDeviceID()
|
||||
assert.match(device, /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/)
|
||||
assert.equal(browserDeviceID(), device)
|
||||
assert.notEqual(newConnectionID(), newConnectionID())
|
||||
values.set('evening-detective-device-ID', 'broken')
|
||||
assert.notEqual(browserDeviceID(), 'broken')
|
||||
})
|
||||
|
||||
test('unavailable storage falls back to a temporary UUID', () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
get() { throw new Error('blocked') },
|
||||
})
|
||||
assert.match(browserDeviceID(), /^[\da-f-]{36}$/)
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const deviceKey = 'evening-detective-device-ID'
|
||||
const uuidPattern = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i
|
||||
|
||||
// getRandomValues also works on a venue served over local HTTP.
|
||||
export function newConnectionID(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
export function browserDeviceID(): string {
|
||||
const temporaryID = newConnectionID()
|
||||
try {
|
||||
const saved = localStorage.getItem(deviceKey)
|
||||
if (saved && uuidPattern.test(saved)) return saved
|
||||
localStorage.setItem(deviceKey, temporaryID)
|
||||
} catch {
|
||||
// Presence still works when browser storage is unavailable.
|
||||
}
|
||||
return temporaryID
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import GamePage from '@/components/GamePage.vue'
|
||||
|
||||
const pageVersion = ref(0)
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GamePage :key="pageVersion" @results-applied="pageVersion++" />
|
||||
<GamePage :key="`${route.params.id}-${pageVersion}`" @results-applied="pageVersion++" />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import TeamStoryPage from '@/components/TeamStoryPage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeamStoryPage />
|
||||
<TeamStoryPage :key="route.fullPath" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user