fix: stabilize mobile uploads and show transfer speed
This commit is contained in:
1 parent
09d66092f5
commit
c0d155c608
12 files changed
+252
-42
No files matched your search
@@ -134,9 +134,24 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||||
|
if (typeof source.stream === 'function') {
|
||||||
yield* chunksOf(source.stream());
|
yield* chunksOf(source.stream());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older Safari releases don't expose Blob.stream(). Read bounded slices
|
||||||
|
// instead of materializing the complete file in memory.
|
||||||
|
const sliceSize = 256 * 1024;
|
||||||
|
for (let offset = 0; offset < source.size; offset += sliceSize) {
|
||||||
|
const part = source.slice(offset, Math.min(offset + sliceSize, source.size));
|
||||||
|
if (typeof part.arrayBuffer === 'function') {
|
||||||
|
yield new Uint8Array(await part.arrayBuffer());
|
||||||
|
} else {
|
||||||
|
yield await readBlobPart(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||||
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
|
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
|
||||||
const reader = source.getReader();
|
const reader = source.getReader();
|
||||||
@@ -153,6 +168,18 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
|
|||||||
yield* source as AsyncIterable<Uint8Array>;
|
yield* source as AsyncIterable<Uint8Array>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBlobPart(blob: Blob): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => reader.result instanceof ArrayBuffer
|
||||||
|
? resolve(new Uint8Array(reader.result))
|
||||||
|
: reject(new TypeError('文件读取失败'));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new TypeError('文件读取失败'));
|
||||||
|
reader.onabort = () => reject(new DOMException('文件读取已取消', 'AbortError'));
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface Chunk {
|
interface Chunk {
|
||||||
data: Bytes;
|
data: Bytes;
|
||||||
last: boolean;
|
last: boolean;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export interface ShareSettings {
|
|||||||
|
|
||||||
/** protocol §7、§14:由帧推导,不单独存储 */
|
/** protocol §7、§14:由帧推导,不单独存储 */
|
||||||
export type ItemState =
|
export type ItemState =
|
||||||
| { kind: 'receiving'; progress: number }
|
| { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number }
|
||||||
| { kind: 'complete' }
|
| { kind: 'complete' }
|
||||||
| { kind: 'incomplete' }
|
| { kind: 'incomplete' }
|
||||||
| { kind: 'tampered' };
|
| { kind: 'tampered' };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { removeChannelItem } from './channelItems';
|
import { removeChannelItem } from './channelItems';
|
||||||
|
import { errorMessage } from '../errors';
|
||||||
|
|
||||||
import { saveResource } from '../database';
|
import { saveResource } from '../database';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ import { deviceIdentity } from './presentation';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { flushOutbox, persistPlan } from './transfer';
|
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||||
import { publishWorkspace } from './presentation';
|
import { publishWorkspace } from './presentation';
|
||||||
|
|
||||||
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||||
@@ -35,24 +36,66 @@ export async function sendFiles(resourceId: string, files: File[]): Promise<void
|
|||||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||||
const device = deviceIdentity();
|
const device = deviceIdentity();
|
||||||
session.deviceName = device.name;
|
session.deviceName = device.name;
|
||||||
|
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts });
|
let item: FileItem | undefined;
|
||||||
let item: FileItem = {
|
try {
|
||||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 },
|
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts, maxFrameData });
|
||||||
|
item = {
|
||||||
|
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||||
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
||||||
};
|
};
|
||||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item.id), item];
|
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
|
||||||
await saveResource(resource);
|
await saveResource(resource);
|
||||||
blobs.set(`${resourceId}:${item.id}`, file);
|
blobs.set(`${resourceId}:${item.id}`, file);
|
||||||
publishWorkspace(resourceId, { t: 'item', item });
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
await persistPlan(resource, plan);
|
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
|
||||||
const uploaded = await flushOutbox(resource, session);
|
if (!item) return;
|
||||||
|
item = {
|
||||||
|
...item,
|
||||||
|
state: {
|
||||||
|
kind: 'receiving',
|
||||||
|
progress: Math.max(0, Math.min(0.99, progress)),
|
||||||
|
phase,
|
||||||
|
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
};
|
||||||
|
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||||
|
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||||
|
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||||
|
});
|
||||||
|
const uploadedFrames = new Set<number>();
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
const uploadStartedAt = performance.now();
|
||||||
|
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||||
|
if (frame.itemId !== item?.id) return;
|
||||||
|
if (complete && !uploadedFrames.has(frame.index)) {
|
||||||
|
uploadedFrames.add(frame.index);
|
||||||
|
uploadedBytes += totalBytes;
|
||||||
|
}
|
||||||
|
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
|
||||||
|
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
|
||||||
|
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
|
||||||
|
});
|
||||||
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||||
resource.localItems = resource.localItems.map((entry) => entry.id === item.id ? item : entry);
|
resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry);
|
||||||
await saveResource(resource);
|
await saveResource(resource);
|
||||||
publishWorkspace(resourceId, { t: 'item', item });
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开频道并连接网络后,可以继续发送已加密内容。' });
|
||||||
|
} catch (error) {
|
||||||
|
if (item) {
|
||||||
|
item = { ...item, state: { kind: 'incomplete' } };
|
||||||
|
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
}
|
||||||
|
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { supportsApiContract } from '../contract';
|
import { supportsApiContract } from '../contract';
|
||||||
|
|
||||||
export type ApiError = Error & { status?: number; code?: string };
|
export type ApiError = Error & { status?: number; code?: string };
|
||||||
let apiBase = new URL('/api/v1', self.location.origin).toString();
|
let apiBase = '/api/v1';
|
||||||
let contractCheck: Promise<void> | undefined;
|
let contractCheck: Promise<void> | undefined;
|
||||||
|
|
||||||
export function setApiBase(value: string): void {
|
export function setApiBase(value: string): void {
|
||||||
@@ -11,9 +11,12 @@ export function setApiBase(value: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
|
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
|
||||||
|
const currentOrigin = (): string => typeof self !== 'undefined' && self.location?.origin
|
||||||
|
? self.location.origin
|
||||||
|
: 'http://localhost';
|
||||||
export const apiPath = (path: string): string => new URL(
|
export const apiPath = (path: string): string => new URL(
|
||||||
`${apiBase.replace(/\/$/, '')}${path}`,
|
`${apiBase.replace(/\/$/, '')}${path}`,
|
||||||
self.location.origin,
|
currentOrigin(),
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
function ensureApiContract(): Promise<void> {
|
function ensureApiContract(): Promise<void> {
|
||||||
@@ -55,6 +58,51 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** XHR reports request-body progress, which fetch doesn't expose in browsers. */
|
||||||
|
export async function apiUpload(
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: ArrayBuffer,
|
||||||
|
onProgress: (loadedBytes: number, totalBytes: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureApiContract();
|
||||||
|
const response = await new Promise<Response>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', apiPath(path));
|
||||||
|
xhr.withCredentials = false;
|
||||||
|
xhr.responseType = 'text';
|
||||||
|
xhr.setRequestHeader('Cache-Control', 'no-store');
|
||||||
|
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) onProgress(event.loaded, event.total);
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 0) {
|
||||||
|
reject(new Error('network_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const contentType = xhr.getResponseHeader('Content-Type');
|
||||||
|
if (contentType) responseHeaders.set('Content-Type', contentType);
|
||||||
|
resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders }));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('network_error'));
|
||||||
|
xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError'));
|
||||||
|
xhr.send(body);
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||||
|
const code = typeof result?.error === 'string' && /^[a-z_]{1,40}$/.test(result.error) ? result.error : 'request_failed';
|
||||||
|
throw fail(response.status, code);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && 'status' in error) throw error;
|
||||||
|
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
|
||||||
|
throw new Error('网络连接失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
|
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
|
||||||
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
|
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
|
||||||
import type { FileItem, TextItem } from '../types';
|
import type { FileItem, TextItem } from '../types';
|
||||||
import { makeFileItem } from './presentation';
|
import { deviceIdentity, makeFileItem } from './presentation';
|
||||||
import { getSession } from './sessions';
|
import { getSession } from './sessions';
|
||||||
import { apiFetch, fail } from './http';
|
import { apiFetch, fail } from './http';
|
||||||
|
|
||||||
|
|
||||||
import { flushOutbox, persistPlan } from './transfer';
|
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||||
import { publishShare } from './presentation';
|
import { publishShare } from './presentation';
|
||||||
import { invalidateManifest } from './shareState';
|
import { invalidateManifest } from './shareState';
|
||||||
import { blobs } from './sessions';
|
import { blobs } from './sessions';
|
||||||
|
import { errorMessage } from '../errors';
|
||||||
|
|
||||||
|
|
||||||
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
|
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
|
||||||
@@ -36,12 +37,15 @@ export async function addShareFiles(id: string, files: File[]): Promise<void> {
|
|||||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||||
await invalidateManifest(session, resource);
|
await invalidateManifest(session, resource);
|
||||||
const downloadLimit = resource.maxDownloads ?? 0;
|
const downloadLimit = resource.maxDownloads ?? 0;
|
||||||
|
const maxFrameData = deviceIdentity().kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
let item: FileItem | undefined;
|
||||||
|
try {
|
||||||
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
|
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
|
||||||
gated: true, maxDownloads: downloadLimit, ts,
|
gated: true, maxDownloads: downloadLimit, ts, maxFrameData,
|
||||||
});
|
});
|
||||||
const item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0 }, downloadLimit || null);
|
item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0, phase: 'encrypting' }, downloadLimit || null);
|
||||||
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
|
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
|
||||||
blobs.set(`${id}:${item.id}`, file);
|
blobs.set(`${id}:${item.id}`, file);
|
||||||
try { await saveDraftFile(id, item.id, file); }
|
try { await saveDraftFile(id, item.id, file); }
|
||||||
@@ -50,15 +54,50 @@ export async function addShareFiles(id: string, files: File[]): Promise<void> {
|
|||||||
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
||||||
await saveResource(resource);
|
await saveResource(resource);
|
||||||
publishShare(id, { t: 'item', item });
|
publishShare(id, { t: 'item', item });
|
||||||
await persistPlan(resource, plan);
|
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
|
||||||
const uploaded = await flushOutbox(resource, session);
|
if (!item) return;
|
||||||
|
item = {
|
||||||
|
...item,
|
||||||
|
state: {
|
||||||
|
kind: 'receiving',
|
||||||
|
progress: Math.max(0, Math.min(0.99, progress)),
|
||||||
|
phase,
|
||||||
|
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
publishShare(id, { t: 'item', item });
|
||||||
|
};
|
||||||
|
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||||
|
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||||
|
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||||
|
});
|
||||||
|
const uploadedFrames = new Set<number>();
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
const uploadStartedAt = performance.now();
|
||||||
|
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||||
|
if (frame.itemId !== item?.id) return;
|
||||||
|
if (complete && !uploadedFrames.has(frame.index)) {
|
||||||
|
uploadedFrames.add(frame.index);
|
||||||
|
uploadedBytes += totalBytes;
|
||||||
|
}
|
||||||
|
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
|
||||||
|
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
|
||||||
|
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
|
||||||
|
});
|
||||||
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||||
resource.draftItems = resource.draftItems.map((draft) => draft.id === item.id ? complete : draft);
|
resource.draftItems = resource.draftItems.map((draft) => draft.id === item?.id ? complete : draft);
|
||||||
await saveResource(resource);
|
await saveResource(resource);
|
||||||
publishShare(id, { t: 'item', item: complete });
|
publishShare(id, { t: 'item', item: complete });
|
||||||
if (!uploaded) {
|
if (!uploaded) publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
|
||||||
publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
|
} catch (error) {
|
||||||
continue;
|
if (item) {
|
||||||
|
item = { ...item, state: { kind: 'incomplete' } };
|
||||||
|
resource.draftItems = (resource.draftItems ?? []).map((draft) => draft.id === item?.id ? item! : draft);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishShare(id, { t: 'item', item });
|
||||||
|
}
|
||||||
|
publishShare(id, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { fromBase64Url, IntegrityError, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||||
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
||||||
import { apiFetch, type ApiError } from './http';
|
import { apiFetch, apiUpload, type ApiError } from './http';
|
||||||
import { serial } from './serial';
|
import { serial } from './serial';
|
||||||
|
|
||||||
export async function persistPlan(resource: SavedResource, plan: ItemPlan): Promise<void> {
|
// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier.
|
||||||
|
export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4;
|
||||||
|
export { MAX_FRAME_DATA };
|
||||||
|
const UPLOAD_RETRY_DELAYS_MS = [500, 1500, 4000] as const;
|
||||||
|
|
||||||
|
export async function persistPlan(
|
||||||
|
resource: SavedResource,
|
||||||
|
plan: ItemPlan,
|
||||||
|
onProgress?: (frames: number, bytes: number) => void,
|
||||||
|
): Promise<{ frames: number; bytes: number }> {
|
||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
|
let frames = 0;
|
||||||
|
let bytes = 0;
|
||||||
for await (const frame of plan.frames) {
|
for await (const frame of plan.frames) {
|
||||||
await queueFrame({
|
await queueFrame({
|
||||||
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
||||||
@@ -15,7 +26,11 @@ export async function persistPlan(resource: SavedResource, plan: ItemPlan): Prom
|
|||||||
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
||||||
createdAt: createdAt + frame.index,
|
createdAt: createdAt + frame.index,
|
||||||
});
|
});
|
||||||
|
frames += 1;
|
||||||
|
bytes += frame.bytes.byteLength;
|
||||||
|
onProgress?.(frames, bytes);
|
||||||
}
|
}
|
||||||
|
return { frames, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeRole(session: ChannelSession): 'owner' | 'write' {
|
function writeRole(session: ChannelSession): 'owner' | 'write' {
|
||||||
@@ -25,29 +40,52 @@ function writeRole(session: ChannelSession): 'owner' | 'write' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
||||||
export async function flushOutbox(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
export async function flushOutbox(
|
||||||
return serial(`outbox:${resource.id}`, () => flush(resource, session));
|
resource: SavedResource,
|
||||||
|
session: ChannelSession,
|
||||||
|
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return serial(`outbox:${resource.id}`, () => flush(resource, session, onFrameProgress));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
async function flush(
|
||||||
|
resource: SavedResource,
|
||||||
|
session: ChannelSession,
|
||||||
|
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const frame = await nextQueuedFrame(resource.id);
|
const frame = await nextQueuedFrame(resource.id);
|
||||||
if (!frame) return true;
|
if (!frame) return true;
|
||||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||||
|
let maxLoadedBytes = 0;
|
||||||
|
let accepted = false;
|
||||||
|
for (let attempt = 0; attempt <= UPLOAD_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
await apiFetch(path, {
|
await apiUpload(
|
||||||
method: 'POST',
|
path,
|
||||||
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
||||||
body: frame.bytes.slice().buffer as ArrayBuffer,
|
frame.bytes.slice().buffer as ArrayBuffer,
|
||||||
});
|
(loaded, total) => {
|
||||||
|
maxLoadedBytes = Math.max(maxLoadedBytes, loaded);
|
||||||
|
onFrameProgress?.(frame, maxLoadedBytes, total, false);
|
||||||
|
},
|
||||||
|
);
|
||||||
await dequeueFrame(frame.key);
|
await dequeueFrame(frame.key);
|
||||||
|
onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true);
|
||||||
|
accepted = true;
|
||||||
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
if (apiError.status === undefined || apiError.status === 429 || apiError.status >= 500) return false;
|
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|
||||||
throw error;
|
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
|
||||||
|
if (!retryable) throw error;
|
||||||
|
if (attempt === UPLOAD_RETRY_DELAYS_MS.length) return false;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAYS_MS[attempt]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!accepted) return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadDecrypted(
|
export async function downloadDecrypted(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
||||||
import { shortDevice, titleOf } from '@/features/item/summary';
|
import { shortDevice, titleOf } from '@/features/item/summary';
|
||||||
@@ -33,6 +33,7 @@ export function LatestCard({ id, onOpen }: Props) {
|
|||||||
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
||||||
<span className={styles.sub}>
|
<span className={styles.sub}>
|
||||||
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||||
|
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { CopyButton } from '@/ui/CopyButton';
|
import { CopyButton } from '@/ui/CopyButton';
|
||||||
import { useDownload } from './download';
|
import { useDownload } from './download';
|
||||||
@@ -42,6 +42,7 @@ export const CompactRow = memo(function CompactRow({ id, selected, checked, onSe
|
|||||||
<>
|
<>
|
||||||
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
|
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
|
||||||
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
|
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
|
||||||
|
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||||
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
|
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
|
||||||
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
|
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
|
||||||
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
|
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Item } from '@/core-bridge/types';
|
import type { Item } from '@/core-bridge/types';
|
||||||
import { isLongText } from '@/lib/fileKind';
|
import { isLongText } from '@/lib/fileKind';
|
||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { CopyButton } from '@/ui/CopyButton';
|
import { CopyButton } from '@/ui/CopyButton';
|
||||||
import { useDownload } from './download';
|
import { useDownload } from './download';
|
||||||
@@ -71,7 +71,7 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{receiving !== null ? <p className={styles.dim}>传输中 {Math.round(receiving * 100)}%,完成后可预览。</p> : null}
|
{receiving !== null && item.state.kind === 'receiving' ? <p className={styles.dim}>{formatTransferProgress(item.state)} · 完成后可预览。</p> : null}
|
||||||
{receiving === null && editing && item.kind === 'text' ? (
|
{receiving === null && editing && item.kind === 'text' ? (
|
||||||
<TextEditor item={item} onDone={() => setEditing(false)} />
|
<TextEditor item={item} onDone={() => setEditing(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize } from '@/lib/format';
|
import { formatSize, formatTransferProgress } from '@/lib/format';
|
||||||
import { useShareSession } from '@/store/shareSession';
|
import { useShareSession } from '@/store/shareSession';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { titleOf } from '@/features/item/summary';
|
import { titleOf } from '@/features/item/summary';
|
||||||
@@ -20,7 +20,7 @@ export function DraftRow({ id, selected, onSelect }: { id: string; selected: boo
|
|||||||
<Tile item={item} />
|
<Tile item={item} />
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
<span className={styles.title}>{titleOf(item)}</span>
|
<span className={styles.title}>{titleOf(item)}</span>
|
||||||
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
|
<span className={styles.sub}>{formatSize(size)}{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}</span>
|
||||||
{receiving !== null ? (
|
{receiving !== null ? (
|
||||||
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
||||||
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export function formatSize(bytes: number): string {
|
|||||||
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTransferProgress(state: {
|
||||||
|
progress: number;
|
||||||
|
phase?: 'encrypting' | 'uploading';
|
||||||
|
speedBytesPerSecond?: number;
|
||||||
|
}): string {
|
||||||
|
const phase = state.phase === 'encrypting' ? '加密中' : state.phase === 'uploading' ? '上传中' : '传输中';
|
||||||
|
const speed = state.speedBytesPerSecond && state.speedBytesPerSecond > 0
|
||||||
|
? ` · ${formatSize(state.speedBytesPerSecond)}/s`
|
||||||
|
: '';
|
||||||
|
return `${phase} ${Math.round(state.progress * 100)}%${speed}`;
|
||||||
|
}
|
||||||
|
|
||||||
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
|
||||||
export function formatTime(ts: number): string {
|
export function formatTime(ts: number): string {
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ summary,
|
|||||||
|
|
||||||
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */
|
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
input,
|
input:not([type="checkbox"]):not([type="radio"]),
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user