feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

+131
View File
@@ -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;
}