Files
WebDrop/web/src/core-bridge/mockShare.ts
T

204 lines
6.7 KiB
TypeScript

import { previewKindOf } from '@/lib/fileKind';
import type { ShareApi } from './shareApi';
import { b64url } from './mockLinks';
import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed';
import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types';
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const CREATOR = 'creator';
export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd';
const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
interface Runtime {
share: Share;
items: Map<string, Item>;
blobs: Map<string, Blob | Promise<Blob>>;
}
/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */
export function createMockShareApi(): ShareApi {
const runtimes = new Map<string, Runtime>();
let current: Runtime | undefined;
let emit: (e: ShareEvent) => void = () => {};
const timers = new Set<number>();
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
const put = (item: Item) => {
current?.items.set(item.id, item);
emit({ t: 'item', item });
};
const draftOnly = () => {
if (!current || current.share.state !== 'draft') throw new Error('forbidden');
return current;
};
function seedDemo(): Runtime {
const share: Share = {
id: DEMO_SHARE_ID,
state: 'sealed',
mine: false,
link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`,
createdAt: Date.now() - 2 * 3600_000,
expiresAt: Date.now() + 22 * 3600_000,
ttlSeconds: 86400,
maxVisitors: 10,
maxDownloads: 5,
};
const rt: Runtime = { share, items: new Map(), blobs: new Map() };
const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']);
for (const item of seedItems().items) {
if (!keep.has(item.id)) continue;
rt.items.set(item.id, item.kind === 'file'
? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads }
: { ...item, deviceId: CREATOR });
}
rt.blobs.set('it-image', makeSvgBlob());
rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
runtimes.set(share.id, rt);
return rt;
}
return {
async listSaved() { return []; },
async forget() {},
async createDraft(settings) {
await wait(300);
const id = b64url(16);
const share: Share = {
id,
state: 'draft',
mine: true,
link: `${location.origin}/s/${id}#k=${b64url(32)}`,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
...settings,
};
runtimes.set(id, { share, items: new Map(), blobs: new Map() });
return share;
},
async updateSettings(id, patch: Partial<ShareSettings>) {
const rt = runtimes.get(id);
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
rt.share = { ...rt.share, ...patch };
emit({ t: 'share', share: rt.share });
return rt.share;
},
async seal(id) {
await wait(300);
const rt = runtimes.get(id);
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
// 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者
rt.share = { ...rt.share, state: 'sealed' };
for (const item of rt.items.values()) {
if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads });
}
emit({ t: 'share', share: rt.share });
for (const item of rt.items.values()) emit({ t: 'item', item });
return rt.share;
},
async join(link) {
await wait(300);
const known = runtimes.get(link.id);
if (known) return known.share;
// 原型里不认识的链接:当作一个已封存、没有内容的分享
const share: Share = {
id: link.id,
state: 'sealed',
mine: false,
link: `${location.origin}/s/${link.id}#k=${link.key}`,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
ttlSeconds: 86400,
maxVisitors: 10,
maxDownloads: null,
};
runtimes.set(link.id, { share, items: new Map(), blobs: new Map() });
return share;
},
loadDemo() {
return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share];
},
connect(shareId, onEvent) {
emit = onEvent;
current = runtimes.get(shareId);
const rt = current;
if (!rt) return () => {};
const id = window.setTimeout(() => {
timers.delete(id);
emit({ t: 'share', share: rt.share });
for (const item of rt.items.values()) emit({ t: 'item', item });
emit({ t: 'ready' });
}, 400);
timers.add(id);
return () => {
for (const t of timers) clearTimeout(t);
timers.clear();
emit = () => {};
};
},
async addText(text) {
draftOnly();
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text });
},
async editText(id, text) {
const rt = draftOnly();
const item = rt.items.get(id);
if (!item || item.kind !== 'text') throw new Error('not_found');
put({ ...item, text, ts: Date.now() });
},
async addFiles(files) {
const rt = draftOnly();
for (const file of files) {
const id = uid();
const base: FileItem = {
id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type, size: file.size, remaining: null,
preview: previewKindOf(file.type, file.name),
};
rt.blobs.set(id, file);
put(base);
for (let p = 0.25; p < 1; p += 0.25) {
await wait(200);
put({ ...base, state: { kind: 'receiving', progress: p } });
}
await wait(200);
put({ ...base, state: { kind: 'complete' } });
}
},
async removeItem(id) {
const rt = draftOnly();
rt.items.delete(id);
rt.blobs.delete(id);
emit({ t: 'removed', id });
},
async getBlob(id) {
const item = current?.items.get(id);
// 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410
if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) {
if (item.remaining <= 0) throw new Error('gone');
put({ ...item, remaining: item.remaining - 1 });
}
const b = current?.blobs.get(id);
if (b) return b;
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
throw new Error('not found');
},
async listArchive() {
await wait(250);
return archiveEntries;
},
};
}