diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts index 227aa28..a8a97bb 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/core-bridge/worker/account.ts @@ -260,7 +260,8 @@ export async function acceptInvitation(channelId: string): Promise { const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId); if (!invitation) throw new Error('邀请已失效'); const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin); - stillActive(session); await joinChannel({ link }); await syncVault(); + // 保险库同步(约 5 次往返)放到后台,不让用户等着:频道资源已在本机保存,可以直接打开 + stillActive(session); await joinChannel({ link }); scheduleSync(); } export async function inviteChannelUser(channelId: string, input: string): Promise { requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId); diff --git a/web/src/core-bridge/worker/channelDataKeys.ts b/web/src/core-bridge/worker/channelDataKeys.ts index 11d65cb..9221ebc 100644 --- a/web/src/core-bridge/worker/channelDataKeys.ts +++ b/web/src/core-bridge/worker/channelDataKeys.ts @@ -27,7 +27,15 @@ export function attachDataKeys(session: ChannelSession, resource: SavedResource) objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped; }; } -export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record }> { +type DataKeys = { state: CryptoState; keys: Record }; +const pendingKeys = new Map>(); +/** 同一空间同时发起的读取合并成一次请求 */ +export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise { + let task = pendingKeys.get(resource.id); + if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); } + return task; +} +async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise { const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise); states.set(resource.id, state); const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys }; diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index 5505a60..3062aa2 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -129,15 +129,19 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS } export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise { - if (resource.mode === 'enc') await readDataKeys(resource, session); + // reset 必须同步发出:connectChannel 随后并行拉取频道信息,它们的结果要落在 reset 之后 publishWorkspace(resource.id, { t: 'reset' }); clearFileCatalog(resource.id); readers.set(resource.id, session.createItemReader()); + // 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返 + const keys = resource.mode === 'enc' ? readDataKeys(resource, session) : undefined; + keys?.catch(() => undefined); let from = 0; - for (;;) { + for (let first = true; ; first = false) { const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, { headers: session.authHeaders('read'), signal, }); + if (first) await keys; const records = parseLogRecords(new Uint8Array(await response.arrayBuffer())); for (const record of records) { await applyChannelRecord(resource, session, record); diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index eb3b11a..7264d69 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -8,7 +8,7 @@ import { channelCache, isOfflineFailure } from './resourceMetadata'; import { accountToken } from './accountSession'; import { attachDataKeys, initializeChannelKeys } from './channelDataKeys'; import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api'; -import { getSession, sessions } from './sessions'; +import { freshlyJoined, getSession, sessions } from './sessions'; import { makeLink } from './links'; import { toChannelInfo, type ChannelWire } from './wire'; import { stopChannel } from './channelTransport'; @@ -90,6 +90,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s }; await saveResource(resource); sessions.set(id, session); + freshlyJoined.set(id, Date.now()); return toChannelInfo(info, resource); } catch (error) { session.dispose(); diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/core-bridge/worker/channelTransport.ts index 1d1c2bb..04416f5 100644 --- a/web/src/core-bridge/worker/channelTransport.ts +++ b/web/src/core-bridge/worker/channelTransport.ts @@ -1,5 +1,5 @@ import { IntegrityError } from '@webdrop/core'; -import { active, getSession } from './sessions'; +import { active, freshlyJoined, getSession } from './sessions'; import { apiFetch, type ApiError } from './http'; import { publishWorkspace } from './presentation'; import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents'; @@ -20,7 +20,9 @@ export async function connectChannel(id: string): Promise { if (signal.aborted) return; if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); // Device sessions can be revoked independently of account membership; rejoin idempotently. - await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal }); + const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id); + if (joinedAt === undefined || Date.now() - joinedAt > 60_000) + await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal }); if (resource.needsJoin) { if (session.can('owner')) await channelMetadata(resource, session); resource.needsJoin = false; @@ -47,9 +49,8 @@ export async function connectChannel(id: string): Promise { while (!signal.aborted) { try { if (!initialized) { - stream.cursor = await syncChannel(resource, session, signal); + [stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session)]); if (signal.aborted) break; - await channelMetadata(resource, session); await flushOutbox(resource, session); initialized = true; publishWorkspace(id, { t: 'ready' }); diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/core-bridge/worker/sessions.ts index f9baeb6..d99c5d0 100644 --- a/web/src/core-bridge/worker/sessions.ts +++ b/web/src/core-bridge/worker/sessions.ts @@ -8,6 +8,8 @@ import { clearWorkspaceStructures } from './workspaceStructure'; import { attachDataKeys, clearDataKeys } from './channelDataKeys'; export const sessions = new Map(); export const readers = new Map>(); +/** 刚加入的频道 → 加入时间;connectChannel 据此跳过紧接着的重复 join(省一次往返) */ +export const freshlyJoined = new Map(); export const active = new Map(); export const blobs = new Map(); const opening = new Map>();