import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database'; import type { FileItem, TextItem } from '../types'; import { deviceIdentity, makeFileItem } from './presentation'; import { getSession } from './sessions'; import { apiFetch, fail } from './http'; import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishShare } from './presentation'; import { invalidateManifest } from './shareState'; import { blobs } from './sessions'; import { errorMessage } from '../errors'; function newTextEntry(text: string): { item: TextItem; entry: Extract } { const ts = Date.now(); const localId = `draft-text-${crypto.randomUUID()}`; return { item: { id: localId, kind: 'text', ts, state: { kind: 'complete' }, text }, entry: { t: 'text', localId, text, ts }, }; } export async function addShareText(id: string, text: string): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); await invalidateManifest(session, resource); const { item, entry } = newTextEntry(text); resource.draftItems = [...(resource.draftItems ?? []), item]; resource.manifestEntries = [...(resource.manifestEntries ?? []), entry]; await saveResource(resource); publishShare(id, { t: 'item', item }); } export async function addShareFiles(id: string, files: File[]): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); await invalidateManifest(session, resource); const downloadLimit = resource.maxDownloads ?? 0; const maxFrameData = (await deviceIdentity(session.endpointSecret)).kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; for (const file of files) { const ts = Date.now(); let item: FileItem | undefined; try { const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, { gated: true, maxDownloads: downloadLimit, ts, maxFrameData, }); item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0, phase: 'encrypting' }, downloadLimit || null); const entry: Extract = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts }; blobs.set(`${id}:${item.id}`, file); try { await saveDraftFile(id, item.id, file); } catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); } resource.draftItems = [...(resource.draftItems ?? []), item]; resource.manifestEntries = [...(resource.manifestEntries ?? []), entry]; await saveResource(resource); publishShare(id, { t: 'item', item }); const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => { if (!item) return; item = { ...item, state: { kind: 'receiving', progress: Math.max(0, Math.min(0.99, progress)), phase, ...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }), }, }; publishShare(id, { t: 'item', item }); }; const planned = await persistPlan(resource, plan, (_frames) => { const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); }); const uploadedFrames = new Set(); let uploadedBytes = 0; const uploadStartedAt = performance.now(); const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { if (frame.itemId !== item?.id) return; if (complete && !uploadedFrames.has(frame.index)) { uploadedFrames.add(frame.index); uploadedBytes += totalBytes; } const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes); const elapsed = Math.max(1, performance.now() - uploadStartedAt); updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed); }); const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; resource.draftItems = resource.draftItems.map((draft) => draft.id === item?.id ? complete : draft); await saveResource(resource); publishShare(id, { t: 'item', item: complete }); if (!uploaded) publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' }); } catch (error) { if (item) { item = { ...item, state: { kind: 'incomplete' } }; resource.draftItems = (resource.draftItems ?? []).map((draft) => draft.id === item?.id ? item! : draft); await saveResource(resource); publishShare(id, { t: 'item', item }); } publishShare(id, { t: 'error', message: `文件上传失败:${errorMessage(error)}` }); throw error; } } } export async function editShareText(id: string, itemId: string, text: string): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); const item = resource.draftItems?.find((entry): entry is TextItem => entry.id === itemId && entry.kind === 'text'); if (!item) throw fail(404, 'not_found'); await invalidateManifest(session, resource); const ts = Date.now(); const updated: TextItem = { ...item, text, ts }; resource.draftItems = (resource.draftItems ?? []).map((entry) => entry.id === itemId ? updated : entry); resource.manifestEntries = (resource.manifestEntries ?? []).map((entry) => entry.t === 'text' && entry.localId === itemId ? { ...entry, text, ts } : entry); await saveResource(resource); publishShare(id, { t: 'item', item: updated }); } export async function removeShareItem(id: string, itemId: string): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); await invalidateManifest(session, resource); const item = resource.draftItems?.find((entry) => entry.id === itemId); if (!item) throw fail(404, 'not_found'); if (item.kind === 'file') { try { await apiFetch(`/shares/${id}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders('owner') }); } catch (error) { if ((error as { status?: number }).status !== 404) throw error; } await dequeueItem(id, itemId); await removeDraftFile(id, itemId); blobs.delete(`${id}:${itemId}`); } resource.draftItems = (resource.draftItems ?? []).filter((entry) => entry.id !== itemId); resource.manifestEntries = (resource.manifestEntries ?? []).filter((entry) => entry.t === 'file' ? entry.id !== itemId : entry.localId !== itemId); await saveResource(resource); publishShare(id, { t: 'removed', id: itemId }); }