feat(web): connect quick share flow

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:37 +08:00
1 parent 53768a195d
commit e02e024605
138 files changed
+10614

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types';
/**
* 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
* 便捷分享有自己的边界,见 shareApi.ts。
*/
export interface WorkspaceApi {
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
createChannel(opts: CreateOptions): Promise<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
/** 仅假实现提供:载入演示用的频道 */
loadDemo?(): ChannelInfo[];
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string): Promise<void>;
sendFiles(files: File[]): Promise<void>;
/** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
editText(id: string, text: string): Promise<void>;
resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>;
remove(id: string): Promise<void>;
setSnapshot(text: string): Promise<void>;
/** 取文件内容(GATED 条目会计一次下载,见 api.md);带 versionId 取旧版本 */
getBlob(id: string, versionId?: string): Promise<Blob>;
listArchive(id: string): Promise<ArchiveEntry[]>;
/** 标记「别的设备的修改」已看过(本地状态) */
markSeen(id: string): Promise<void>;
/** 仅假实现提供,用于演示连接状态 */
simulateConnection?(state: ConnectionState): void;
}
+8
View File
@@ -0,0 +1,8 @@
import type { WorkspaceApi } from './api';
import { createMockApi } from './mock';
import { createMockShareApi } from './mockShare';
import type { ShareApi } from './shareApi';
/** 接真实后端时,只改这两行 */
export const api: WorkspaceApi = createMockApi();
export const shareApi: ShareApi = createMockShareApi();
+209
View File
@@ -0,0 +1,209 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import { previewKindOf } from '@/lib/fileKind';
import type { WorkspaceApi } from './api';
import { b64url } from './mockLinks';
import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, Item, Snapshot, WorkspaceEvent } from './types';
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** protocol §2:链接形态。enc 带 k,raw 不带 */
function buildLinks(id: string, o: CreateOptions): Pick<ChannelInfo, 'memberLink' | 'ownerLink'> {
const base = `${location.origin}/c/${id}`;
const a = b64url(32);
if (o.mode === 'enc') {
const k = b64url(32);
return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` };
}
const w = o.membersCanUpload ? b64url(32) : null;
return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` };
}
interface Runtime {
info: ChannelInfo;
items: Map<string, Item>;
blobs: Map<string, Blob | Promise<Blob>>;
devices: Device[];
snapshot: Snapshot;
demo: boolean;
}
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true };
/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
export function createMockApi(): WorkspaceApi {
const runtimes = new Map<string, Runtime>();
const timers = new Set<number>();
let current: Runtime | undefined;
let emit: (e: WorkspaceEvent) => void = () => {};
const later = (ms: number, fn: () => void) => {
const id = window.setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
};
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
const put = (item: Item) => {
current?.items.set(item.id, item);
emit({ t: 'item', item });
};
function newRuntime(info: ChannelInfo, devices: Device[]): Runtime {
const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false };
runtimes.set(info.id, rt);
return rt;
}
function seedDemo(): Runtime {
const rt = newRuntime(demoChannel, demoDevices);
rt.demo = true;
rt.snapshot = demoSnapshot;
for (const item of seedItems().items) rt.items.set(item.id, item);
rt.blobs.set('it-image', makeSvgBlob());
rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported'))));
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' }));
return rt;
}
/** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
function runDemoActivity(rt: Runtime) {
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
later(14000, () => {
const cur = rt.items.get('it-image');
if (cur?.kind !== 'file') return;
const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size };
put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true });
});
later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) }));
later(11000, () => emit({ t: 'devices', devices: rt.devices }));
}
return {
async createChannel(opts) {
await wait(300);
const id = b64url(16);
const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt: Date.now(), ...buildLinks(id, opts) };
newRuntime(info, [selfDevice]);
return info;
},
async joinChannel(link: ParsedChannelLink) {
await wait(500);
const known = runtimes.get(link.id);
if (known) return { ...known.info, role: 'member', ownerLink: undefined };
const base = `${location.origin}/c/${link.id}`;
const frag = (keys: Record<string, string | undefined>) =>
Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&');
const member = frag({ k: link.keys.k, w: link.keys.w });
const info: ChannelInfo = {
id: link.id,
mode: link.mode,
role: link.isOwner ? 'owner' : 'member',
seats: 3,
createdAt: Date.now(),
memberLink: member ? `${base}#${member}` : base,
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
};
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', online: true }]);
return info;
},
loadDemo() {
return [(runtimes.get(demoChannel.id) ?? seedDemo()).info];
},
connect(channelId, onEvent) {
emit = onEvent;
current = runtimes.get(channelId);
const rt = current;
if (!rt) return () => {};
later(500, () => {
emit({ t: 'channel', info: rt.info });
emit({ t: 'devices', devices: rt.devices });
emit({ t: 'snapshot', snapshot: rt.snapshot });
for (const item of rt.items.values()) emit({ t: 'item', item });
emit({ t: 'connection', state: 'online' });
emit({ t: 'ready' });
});
if (rt.demo) runDemoActivity(rt);
return () => {
for (const id of timers) clearTimeout(id);
timers.clear();
emit = () => {};
};
},
async sendText(text) {
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text });
},
async sendFiles(files) {
for (const file of files) {
const id = uid();
const base: FileItem = {
id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type, size: file.size, remaining: 10,
preview: previewKindOf(file.type, file.name),
};
current?.blobs.set(id, file);
put(base);
for (let p = 0.2; p < 1; p += 0.2) {
await wait(250);
put({ ...base, state: { kind: 'receiving', progress: p } });
}
await wait(250);
put({ ...base, state: { kind: 'complete' } });
}
},
async editText(id, text) {
const cur = current?.items.get(id);
if (cur?.kind !== 'text') return;
put({ ...cur, text, editedAt: Date.now() });
},
async resolveConflict(id, choice) {
const cur = current?.items.get(id);
if (cur?.kind !== 'text' || !cur.conflict) return;
put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined });
},
async remove(id) {
current?.items.delete(id);
current?.blobs.delete(id);
emit({ t: 'removed', id });
},
async setSnapshot(text) {
const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() };
if (current) current.snapshot = snapshot;
emit({ t: 'snapshot', snapshot });
},
async getBlob(id) {
const b = current?.blobs.get(id);
if (b) return b;
const item = current?.items.get(id);
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
throw new Error('not found');
},
async markSeen(id) {
const cur = current?.items.get(id);
if (cur?.kind !== 'file' || !cur.unseen) return;
put({ ...cur, unseen: false });
},
async listArchive() {
await wait(250);
return archiveEntries;
},
simulateConnection(state: ConnectionState) {
emit({ t: 'connection', state });
},
};
}
+5
View File
@@ -0,0 +1,5 @@
/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */
export function b64url(bytes: number): string {
const b = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
+145
View File
@@ -0,0 +1,145 @@
import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types';
/** 仅供假实现使用的演示数据与演示用文件内容 */
export const SELF = 'self';
export const PHONE = 'phone';
export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
export const devices: Device[] = [
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true },
{ id: PHONE, name: 'iPhone', kind: 'phone', online: true },
{ id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 },
];
export const channel: ChannelInfo = {
id: DEMO_ID,
mode: 'enc',
role: 'owner',
seats: 3,
createdAt: Date.now() - 3 * 3600_000,
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`,
};
export const snapshot: Snapshot = {
text: '待办\n- 取快递\n- 发周报\n- 备份相册',
updatedBy: PHONE,
updatedAt: Date.now() - 20 * 60_000,
};
export const archiveEntries: ArchiveEntry[] = [
{ path: 'project-assets/', size: 0, dir: true },
{ path: 'project-assets/README.md', size: 2_140, dir: false },
{ path: 'project-assets/design/', size: 0, dir: true },
{ path: 'project-assets/design/logo.svg', size: 8_420, dir: false },
{ path: 'project-assets/design/cover.png', size: 3_281_990, dir: false },
{ path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false },
{ path: 'project-assets/fonts/', size: 0, dir: true },
{ path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false },
{ path: 'project-assets/data/', size: 0, dir: true },
{ path: 'project-assets/data/export.csv', size: 412_873, dir: false },
{ path: 'project-assets/notes.txt', size: 1_822, dir: false },
];
const ago = (min: number) => Date.now() - min * 60_000;
export function seedItems(): { items: Item[]; sizeOverride: Record<string, number> } {
const logText = Array.from({ length: 180 }, (_, i) => {
const level = i % 17 === 0 ? 'WARN' : 'INFO';
return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`;
}).join('\n');
const items: Item[] = [
{
id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' },
name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video',
},
{
id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' },
name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive',
},
{
id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' },
name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image',
versions: [
{ id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 },
{ id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 },
],
unseen: true,
},
{
id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' },
text: logText,
},
{
id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' },
text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' },
},
{
id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' },
text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}',
},
{
id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' },
text: '明天 10 点开会,带电脑',
},
{
id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' },
name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none',
},
];
return { items, sizeOverride: {} };
}
export function makeSvgBlob(): Blob {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d9d9d4"/><stop offset="1" stop-color="#8f8f88"/></linearGradient></defs>
<rect width="640" height="400" fill="url(#g)"/>
<rect x="60" y="60" width="520" height="280" rx="12" fill="#fff" opacity=".85"/>
<text x="320" y="215" font-size="42" text-anchor="middle" font-family="sans-serif" fill="#1a1a1a">设计稿 · 演示图片</text></svg>`;
return new Blob([svg], { type: 'image/svg+xml' });
}
/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */
export async function makeVideoBlob(): Promise<Blob | null> {
try {
if (typeof MediaRecorder === 'undefined') return null;
const canvas = document.createElement('canvas');
canvas.width = 480;
canvas.height = 270;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' });
const chunks: Blob[] = [];
rec.ondataavailable = (e) => chunks.push(e.data);
const done = new Promise<Blob>((res) => {
rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' }));
});
rec.start();
const t0 = performance.now();
await new Promise<void>((resolve) => {
const draw = () => {
const t = (performance.now() - t0) / 1000;
ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '28px sans-serif';
ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140);
if (t < 3) requestAnimationFrame(draw);
else resolve();
};
draw();
});
rec.stop();
return await done;
} catch {
return null;
}
}
export function makeLogBlob(text: string): Blob {
return new Blob([text], { type: 'text/plain' });
}
+191
View File
@@ -0,0 +1,191 @@
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,
ttlSeconds: 86400,
seats: 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 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(),
...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(),
ttlSeconds: 86400,
seats: 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 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;
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { ParsedShareLink } from '@/lib/shareLink';
import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
/**
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/
export interface ShareApi {
/** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */
createDraft(settings: ShareSettings): Promise<Share>;
/** 草稿阶段调整设置(PATCH settings,需所有者令牌) */
updateSettings(id: string, patch: Partial<ShareSettings>): Promise<Share>;
/**
* 完成分享:创建者丢弃所有者密钥 A,此后任何人都不能追加、改设置或删除,只能等 TTL。
* 客户端丢弃是自觉行为;让服务端也强制,需要后端提供「封存」使 owner_hash 失效。
*/
seal(id: string): Promise<Share>;
/** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */
join(link: ParsedShareLink): Promise<Share>;
/** 仅假实现提供:载入演示用的分享 */
loadDemo?(): Share[];
/** 订阅某个分享的事件流,返回取消订阅函数 */
connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
/** 草稿阶段添加与移除内容 */
addText(text: string): Promise<void>;
addFiles(files: File[]): Promise<void>;
removeItem(id: string): Promise<void>;
/** 取内容(封存后每次拉取消耗一次下载,见 api.md) */
getBlob(id: string): Promise<Blob>;
listArchive(id: string): Promise<ArchiveEntry[]>;
}
+138
View File
@@ -0,0 +1,138 @@
export type DeviceKind = 'desktop' | 'phone' | 'tablet';
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
export interface Device {
id: string;
name: string;
kind: DeviceKind;
online: boolean;
self?: boolean;
/** 正在编辑便签 */
editing?: boolean;
lastSeen?: number;
}
/** 常驻频道:自己多台设备之间长期同步 */
export interface ChannelInfo {
id: string;
mode: 'enc' | 'raw';
role: 'owner' | 'member';
seats: number;
memberLink: string;
/** 只有所有者有 */
ownerLink?: string;
createdAt: number;
}
/** 新建频道的选项(protocol §12 设置的子集) */
export interface CreateOptions {
mode: 'enc' | 'raw';
seats: number;
ttlSeconds: number;
membersCanUpload: boolean;
}
/**
* 便捷分享:把一批文件和文本做成只读清单发给别人。草稿阶段创建者握着所有者密钥,
* 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。
*/
export interface Share {
id: string;
/** draft:创建者可编辑;sealed:已封存,只读 */
state: 'draft' | 'sealed';
/** 创建在本机 */
mine: boolean;
/** 访客链接 `/s/:id#k=…`。草稿阶段不展示 */
link: string;
createdAt: number;
ttlSeconds: number;
/** 最多访客(创建者的设置,不向访客展示) */
seats: number;
/** 每个文件最多下载次数,null 为不限(protocol §12 file_max_downloads) */
maxDownloads: number | null;
}
/** 草稿阶段可调整的设置 */
export interface ShareSettings {
ttlSeconds: number;
seats: number;
maxDownloads: number | null;
}
/** protocol §7、§14:由帧推导,不单独存储 */
export type ItemState =
| { kind: 'receiving'; progress: number }
| { kind: 'complete' }
| { kind: 'incomplete' }
| { kind: 'tampered' };
interface ItemBase {
id: string;
ts: number;
deviceId: string;
state: ItemState;
}
export interface TextItem extends ItemBase {
kind: 'text';
text: string;
editedAt?: number;
/** 另一台设备同时改过(后写者胜,见 ui-design §8) */
conflict?: { deviceId: string; text: string };
}
export interface FileItem extends ItemBase {
kind: 'file';
name: string;
mime: string;
size: number;
/** 剩余下载次数,null 为不限(protocol §12 file_max_downloads) */
remaining: number | null;
preview: PreviewKind;
/**
* 旧版本,新的在前,不含当前版本。
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
*/
versions?: FileVersion[];
/** 当前版本是别的设备改的,而你还没看过 */
unseen?: boolean;
}
export interface FileVersion {
id: string;
ts: number;
deviceId: string;
size: number;
}
export type Item = TextItem | FileItem;
export interface ArchiveEntry {
path: string;
size: number;
dir: boolean;
}
/** protocol §9:频道唯一的快照槽,最后写入者胜 */
export interface Snapshot {
text: string;
updatedBy: string;
updatedAt: number;
}
export type WorkspaceEvent =
| { t: 'ready' }
| { t: 'connection'; state: ConnectionState }
| { t: 'channel'; info: ChannelInfo }
| { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item }
| { t: 'removed'; id: string }
| { t: 'snapshot'; snapshot: Snapshot };
export type ShareEvent =
| { t: 'ready' }
| { t: 'share'; share: Share }
| { t: 'item'; item: Item }
| { t: 'removed'; id: string };