import fs from 'node:fs/promises'; import { session } from 'electron'; import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids'; import type { PlayersStore } from '../players/playersStore'; import type { ZipProjectStore } from '../project/zipStore'; import type { TokensStore } from '../tokens/tokensStore'; type ReadInfo = { absPath: string; mime: string }; async function serveFile(info: ReadInfo, request: Request): Promise { try { const stat = await fs.stat(info.absPath); const total = stat.size; const range = request.headers.get('range') ?? request.headers.get('Range'); if (range) { const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim()); if (m) { const start = Number(m[1]); const endRaw = m[2] ? Number(m[2]) : total - 1; const end = Math.min(endRaw, total - 1); if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) { return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${String(total)}`, 'Cache-Control': 'no-store', }, }); } const len = end - start + 1; const fh = await fs.open(info.absPath, 'r'); try { const buf = Buffer.alloc(len); const { bytesRead } = await fh.read(buf, 0, len, start); if (bytesRead <= 0) { return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${String(total)}`, 'Cache-Control': 'no-store', }, }); } const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead)); const actualEnd = start + bytesRead - 1; return new Response(body, { status: 206, headers: { 'Content-Type': info.mime, 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`, 'Content-Length': String(body.length), 'Cache-Control': 'no-store', }, }); } finally { await fh.close(); } } } const buf = await fs.readFile(info.absPath); return new Response(buf, { headers: { 'Content-Type': info.mime, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store', }, }); } catch { return new Response(null, { status: 404 }); } } /** * Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`. */ export function registerDndAssetProtocol( projectStore: ZipProjectStore, tokensStore: TokensStore, playersStore: PlayersStore, ): void { session.defaultSession.protocol.handle('dnd', async (request) => { const url = new URL(request.url); const id = url.searchParams.get('id'); if (!id) { return new Response(null, { status: 404 }); } let info: ReadInfo | null = null; if (url.hostname === 'asset') { info = projectStore.getAssetReadInfo(asAssetId(id)); } else if (url.hostname === 'token') { info = tokensStore.getImageReadInfo(asTokenId(id)); } else if (url.hostname === 'player') { info = playersStore.getImageReadInfo(asPlayerId(id)); } if (!info) { return new Response(null, { status: 404 }); } return serveFile(info, request); }); }