feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
|
||||
import { getResource, saveResource, type SavedResource } from '../database';
|
||||
import type { FileItem, Item, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { blobs, readers } from './sessions';
|
||||
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
const timestamp = item.meta?.ts ?? Date.now();
|
||||
const deviceId = item.meta?.dev ?? '未知设备';
|
||||
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
|
||||
if (item.kind === 'file' || item.kind === 'raw') {
|
||||
const name = item.meta?.name ?? '加密文件';
|
||||
return {
|
||||
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
|
||||
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
|
||||
remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name),
|
||||
gated: item.gated,
|
||||
};
|
||||
}
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) };
|
||||
}
|
||||
|
||||
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
|
||||
let reader = readers.get(resource.id);
|
||||
if (!reader) {
|
||||
reader = session.createItemReader();
|
||||
readers.set(resource.id, reader);
|
||||
}
|
||||
const events = await reader.push(input);
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'item': {
|
||||
const item = mapReceived(event.item);
|
||||
if (event.item.snapshot) {
|
||||
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
|
||||
if (item.kind === 'text') {
|
||||
try {
|
||||
const value = JSON.parse(item.text) as Partial<Snapshot>;
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
|
||||
else snapshot.text = item.text;
|
||||
} catch { snapshot.text = item.text; }
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'snapshot', snapshot });
|
||||
} else {
|
||||
const blob = item.kind === 'file' && event.item.complete ? new Blob([event.item.content], { type: item.mime }) : undefined;
|
||||
if (blob) blobs.set(`${resource.id}:${item.id}`, blob);
|
||||
publishWorkspace(resource.id, { t: 'item', item });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'announce': {
|
||||
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
|
||||
const placeholder: FileItem = local?.kind === 'file' ? local : {
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
|
||||
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
|
||||
gated: true,
|
||||
};
|
||||
publishWorkspace(resource.id, { t: 'item', item: placeholder });
|
||||
break;
|
||||
}
|
||||
case 'removed':
|
||||
blobs.delete(`${resource.id}:${event.itemId}`);
|
||||
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
|
||||
break;
|
||||
case 'rejected':
|
||||
showRejected(resource.id, event);
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = event;
|
||||
throw new Error(`未知的加密事件 ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
|
||||
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
resource.membersCanUpload = info.settings.members_can_upload;
|
||||
resource.expiresAt = info.expires_at;
|
||||
const latest = await getResource(resource.id);
|
||||
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
|
||||
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
const ownId = toBase64Url(session.endpointHash);
|
||||
let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)];
|
||||
if (session.can('owner')) {
|
||||
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
devices = await Promise.all(seats.map(async (seat) => {
|
||||
let name: string | undefined;
|
||||
if (seat.label) {
|
||||
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
|
||||
catch { name = '设备名校验失败'; }
|
||||
}
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId);
|
||||
}));
|
||||
const own = devices.find((device) => device.self);
|
||||
if (own) own.name = deviceIdentity().name;
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'devices', devices });
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
publishWorkspace(resource.id, { t: 'reset' });
|
||||
readers.set(resource.id, session.createItemReader());
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
|
||||
headers: session.authHeaders('read'), signal,
|
||||
});
|
||||
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
|
||||
for (const record of records) {
|
||||
await applyChannelRecord(resource, session, record);
|
||||
from = Math.max(from, record.seq + 1);
|
||||
}
|
||||
if (records.length < 200) return from;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processChannelBytes(resource: SavedResource, session: ChannelSession, bytes: Uint8Array): Promise<number> {
|
||||
const records = parseLogRecords(bytes);
|
||||
let next = 0;
|
||||
for (const record of records) {
|
||||
await applyChannelRecord(resource, session, record);
|
||||
next = Math.max(next, record.seq + 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
Reference in new issue
Block a user