Fix Android WebView API access and mobile shell layout
This commit is contained in:
@@ -35,6 +35,7 @@ class MemoryStorage implements Storage {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStorage = globalThis.localStorage;
|
||||
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const originalLocation = globalThis.location;
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static latest: FakeXMLHttpRequest | null = null;
|
||||
@@ -136,6 +137,10 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalXMLHttpRequest,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
||||
@@ -180,6 +185,74 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
|
||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('http://localhost'),
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await apiRequest<{ok: boolean}>('/files/recent');
|
||||
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://localhost'),
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await apiRequest<{ok: boolean}>('/files/recent');
|
||||
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest throws backend message on business error', async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
|
||||
@@ -27,8 +27,9 @@ interface ApiBinaryUploadRequestInit {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
||||
const DEFAULT_API_BASE_URL = '/api';
|
||||
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
|
||||
|
||||
let refreshRequestPromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -90,13 +91,57 @@ function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attemp
|
||||
return getRetryDelayMs(attempt);
|
||||
}
|
||||
|
||||
function resolveRuntimeLocation() {
|
||||
if (typeof globalThis.location !== 'undefined') {
|
||||
return globalThis.location;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCapacitorLocalhostOrigin(location: Location | URL | null) {
|
||||
if (!location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const protocol = location.protocol || '';
|
||||
const hostname = location.hostname || '';
|
||||
const port = location.port || '';
|
||||
|
||||
if (protocol === 'capacitor:') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
|
||||
|
||||
return isCapacitorLocalScheme && isLocalhostHost && port === '';
|
||||
}
|
||||
|
||||
export function getApiBaseUrl() {
|
||||
const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, '');
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
|
||||
if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) {
|
||||
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`;
|
||||
}
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveUrl(path: string) {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
return `${getApiBaseUrl()}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
|
||||
44
front/src/lib/transfer-ice.test.ts
Normal file
44
front/src/lib/transfer-ice.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
hasRelayTransferIceServer,
|
||||
resolveTransferIceServers,
|
||||
} from './transfer-ice';
|
||||
|
||||
test('resolveTransferIceServers falls back to the default STUN list when no custom config is provided', () => {
|
||||
assert.deepEqual(resolveTransferIceServers(), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
assert.deepEqual(resolveTransferIceServers(''), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
assert.deepEqual(resolveTransferIceServers('not-json'), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
});
|
||||
|
||||
test('resolveTransferIceServers appends custom TURN servers after the default STUN list', () => {
|
||||
const iceServers = resolveTransferIceServers(JSON.stringify([
|
||||
{
|
||||
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(iceServers, [
|
||||
...DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
{
|
||||
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('hasRelayTransferIceServer detects whether TURN relay servers are configured', () => {
|
||||
assert.equal(hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS), false);
|
||||
assert.equal(hasRelayTransferIceServer(resolveTransferIceServers(JSON.stringify([
|
||||
{
|
||||
urls: 'turn:turn.yoyuzh.xyz:3478?transport=udp',
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]))), true);
|
||||
});
|
||||
91
front/src/lib/transfer-ice.ts
Normal file
91
front/src/lib/transfer-ice.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const DEFAULT_STUN_ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
];
|
||||
|
||||
const RELAY_HINT =
|
||||
'当前环境只配置了 STUN,跨运营商或手机移动网络通常还需要 TURN 中继。';
|
||||
|
||||
type RawIceServer = {
|
||||
urls?: unknown;
|
||||
username?: unknown;
|
||||
credential?: unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS = DEFAULT_STUN_ICE_SERVERS;
|
||||
|
||||
export function resolveTransferIceServers(rawConfig = import.meta.env?.VITE_TRANSFER_ICE_SERVERS_JSON) {
|
||||
if (typeof rawConfig !== 'string' || !rawConfig.trim()) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawConfig) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
const customServers = parsed
|
||||
.map(normalizeIceServer)
|
||||
.filter((server): server is RTCIceServer => server != null);
|
||||
|
||||
if (customServers.length === 0) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
return [...DEFAULT_TRANSFER_ICE_SERVERS, ...customServers];
|
||||
} catch {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRelayTransferIceServer(iceServers: RTCIceServer[]) {
|
||||
return iceServers.some((server) => toUrls(server.urls).some((url) => /^turns?:/i.test(url)));
|
||||
}
|
||||
|
||||
export function appendTransferRelayHint(message: string, hasRelaySupport: boolean) {
|
||||
const normalizedMessage = message.trim();
|
||||
if (!normalizedMessage || hasRelaySupport || normalizedMessage.includes(RELAY_HINT)) {
|
||||
return normalizedMessage;
|
||||
}
|
||||
return `${normalizedMessage} ${RELAY_HINT}`;
|
||||
}
|
||||
|
||||
function normalizeIceServer(rawServer: RawIceServer) {
|
||||
const urls = normalizeUrls(rawServer?.urls);
|
||||
if (urls == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server: RTCIceServer = { urls };
|
||||
if (typeof rawServer.username === 'string' && rawServer.username.trim()) {
|
||||
server.username = rawServer.username.trim();
|
||||
}
|
||||
if (typeof rawServer.credential === 'string' && rawServer.credential.trim()) {
|
||||
server.credential = rawServer.credential.trim();
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
function normalizeUrls(rawUrls: unknown): string | string[] | null {
|
||||
if (typeof rawUrls === 'string' && rawUrls.trim()) {
|
||||
return rawUrls.trim();
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawUrls)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urls = rawUrls
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim());
|
||||
|
||||
return urls.length > 0 ? urls : null;
|
||||
}
|
||||
|
||||
function toUrls(urls: string | string[] | undefined) {
|
||||
if (!urls) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
153
front/src/lib/transfer-peer.test.ts
Normal file
153
front/src/lib/transfer-peer.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createTransferPeer,
|
||||
parseTransferPeerSignal,
|
||||
serializeTransferPeerSignal,
|
||||
type TransferPeerPayload,
|
||||
} from './transfer-peer';
|
||||
|
||||
class FakePeer extends EventEmitter {
|
||||
destroyed = false;
|
||||
sent: Array<string | Uint8Array | ArrayBuffer> = [];
|
||||
signaled: unknown[] = [];
|
||||
writeReturnValue = true;
|
||||
bufferSize = 0;
|
||||
|
||||
send(payload: string | Uint8Array | ArrayBuffer) {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
write(payload: string | Uint8Array | ArrayBuffer) {
|
||||
this.sent.push(payload);
|
||||
return this.writeReturnValue;
|
||||
}
|
||||
|
||||
signal(payload: unknown) {
|
||||
this.signaled.push(payload);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
test('serializeTransferPeerSignal and parseTransferPeerSignal preserve signal payloads', () => {
|
||||
const payload = {
|
||||
type: 'offer' as const,
|
||||
sdp: 'v=0',
|
||||
};
|
||||
|
||||
assert.deepEqual(parseTransferPeerSignal(serializeTransferPeerSignal(payload)), payload);
|
||||
});
|
||||
|
||||
test('createTransferPeer forwards local simple-peer signals to the app layer', () => {
|
||||
const fakePeer = new FakePeer();
|
||||
const seenSignals: string[] = [];
|
||||
let receivedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
createTransferPeer({
|
||||
initiator: true,
|
||||
onSignal: (payload) => {
|
||||
seenSignals.push(payload);
|
||||
},
|
||||
createPeer: (options) => {
|
||||
receivedOptions = options as Record<string, unknown>;
|
||||
return fakePeer as never;
|
||||
},
|
||||
});
|
||||
|
||||
fakePeer.emit('signal', {
|
||||
type: 'answer' as const,
|
||||
sdp: 'v=0',
|
||||
});
|
||||
|
||||
assert.deepEqual(seenSignals, [JSON.stringify({ type: 'answer', sdp: 'v=0' })]);
|
||||
assert.equal(receivedOptions?.objectMode, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer routes remote signals, data, connect, close, and error events through the adapter', () => {
|
||||
const fakePeer = new FakePeer();
|
||||
let connected = 0;
|
||||
let closed = 0;
|
||||
const dataPayloads: TransferPeerPayload[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const peer = createTransferPeer({
|
||||
initiator: false,
|
||||
onConnect: () => {
|
||||
connected += 1;
|
||||
},
|
||||
onData: (payload) => {
|
||||
dataPayloads.push(payload);
|
||||
},
|
||||
onClose: () => {
|
||||
closed += 1;
|
||||
},
|
||||
onError: (error) => {
|
||||
errors.push(error.message);
|
||||
},
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
peer.applyRemoteSignal(JSON.stringify({ candidate: 'candidate:1' }));
|
||||
peer.send('hello');
|
||||
fakePeer.emit('connect');
|
||||
fakePeer.emit('data', 'payload');
|
||||
fakePeer.emit('error', new Error('boom'));
|
||||
peer.destroy();
|
||||
|
||||
assert.deepEqual(fakePeer.signaled, [{ candidate: 'candidate:1' }]);
|
||||
assert.deepEqual(fakePeer.sent, ['hello']);
|
||||
assert.equal(connected, 1);
|
||||
assert.deepEqual(dataPayloads, ['payload']);
|
||||
assert.deepEqual(errors, ['boom']);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(fakePeer.destroyed, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer waits for drain when the wrapped peer applies backpressure', async () => {
|
||||
const fakePeer = new FakePeer();
|
||||
fakePeer.bufferSize = 2048;
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
const writePromise = peer.write('chunk').then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(completed, false);
|
||||
|
||||
fakePeer.emit('drain');
|
||||
await writePromise;
|
||||
assert.equal(completed, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer falls back to bufferSize polling when drain is not emitted', async () => {
|
||||
const fakePeer = new FakePeer();
|
||||
fakePeer.bufferSize = 2048;
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
const writePromise = peer.write('chunk').then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(completed, false);
|
||||
|
||||
fakePeer.bufferSize = 0;
|
||||
await writePromise;
|
||||
assert.equal(completed, true);
|
||||
assert.deepEqual(fakePeer.sent, ['chunk']);
|
||||
});
|
||||
138
front/src/lib/transfer-peer.ts
Normal file
138
front/src/lib/transfer-peer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import Peer from 'simple-peer/simplepeer.min.js';
|
||||
import type { Instance as SimplePeerInstance, Options as SimplePeerOptions, SignalData } from 'simple-peer';
|
||||
|
||||
export type TransferPeerPayload = string | Uint8Array | ArrayBuffer | Blob;
|
||||
|
||||
const TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS = 16;
|
||||
|
||||
interface TransferPeerLike {
|
||||
bufferSize?: number;
|
||||
connected?: boolean;
|
||||
destroyed?: boolean;
|
||||
on(event: 'signal', listener: (signal: SignalData) => void): this;
|
||||
on(event: 'connect', listener: () => void): this;
|
||||
on(event: 'data', listener: (data: TransferPeerPayload) => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
on(event: 'error', listener: (error: Error) => void): this;
|
||||
once?(event: 'drain', listener: () => void): this;
|
||||
removeListener?(event: 'drain', listener: () => void): this;
|
||||
signal(signal: SignalData): void;
|
||||
send(payload: TransferPeerPayload): void;
|
||||
write?(payload: TransferPeerPayload): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface TransferPeerAdapter {
|
||||
readonly connected: boolean;
|
||||
readonly destroyed: boolean;
|
||||
applyRemoteSignal(payload: string): void;
|
||||
send(payload: TransferPeerPayload): void;
|
||||
write(payload: TransferPeerPayload): Promise<void>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface CreateTransferPeerOptions {
|
||||
initiator: boolean;
|
||||
trickle?: boolean;
|
||||
peerOptions?: Omit<SimplePeerOptions, 'initiator' | 'trickle'>;
|
||||
onSignal?: (payload: string) => void;
|
||||
onConnect?: () => void;
|
||||
onData?: (payload: TransferPeerPayload) => void;
|
||||
onClose?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
createPeer?: (options: SimplePeerOptions) => TransferPeerLike;
|
||||
}
|
||||
|
||||
export function serializeTransferPeerSignal(signal: SignalData) {
|
||||
return JSON.stringify(signal);
|
||||
}
|
||||
|
||||
export function parseTransferPeerSignal(payload: string) {
|
||||
return JSON.parse(payload) as SignalData;
|
||||
}
|
||||
|
||||
function waitForPeerBufferToClear(peer: TransferPeerLike) {
|
||||
if (!peer.bufferSize || peer.bufferSize <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
if (peer.removeListener) {
|
||||
peer.removeListener('drain', finish);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
peer.once?.('drain', finish);
|
||||
pollTimer = setInterval(() => {
|
||||
if (peer.destroyed || !peer.bufferSize || peer.bufferSize <= 0) {
|
||||
finish();
|
||||
}
|
||||
}, TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export function createTransferPeer(options: CreateTransferPeerOptions): TransferPeerAdapter {
|
||||
const peerFactory = options.createPeer ?? ((peerOptions: SimplePeerOptions) => new Peer(peerOptions) as SimplePeerInstance);
|
||||
const peer = peerFactory({
|
||||
initiator: options.initiator,
|
||||
objectMode: true,
|
||||
trickle: options.trickle ?? true,
|
||||
...options.peerOptions,
|
||||
});
|
||||
|
||||
peer.on('signal', (signal) => {
|
||||
options.onSignal?.(serializeTransferPeerSignal(signal));
|
||||
});
|
||||
peer.on('connect', () => {
|
||||
options.onConnect?.();
|
||||
});
|
||||
peer.on('data', (payload) => {
|
||||
options.onData?.(payload);
|
||||
});
|
||||
peer.on('close', () => {
|
||||
options.onClose?.();
|
||||
});
|
||||
peer.on('error', (error) => {
|
||||
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
return {
|
||||
get connected() {
|
||||
return Boolean(peer.connected);
|
||||
},
|
||||
get destroyed() {
|
||||
return Boolean(peer.destroyed);
|
||||
},
|
||||
applyRemoteSignal(payload: string) {
|
||||
peer.signal(parseTransferPeerSignal(payload));
|
||||
},
|
||||
send(payload: TransferPeerPayload) {
|
||||
peer.send(payload);
|
||||
},
|
||||
async write(payload: TransferPeerPayload) {
|
||||
if (!peer.write) {
|
||||
peer.send(payload);
|
||||
await waitForPeerBufferToClear(peer);
|
||||
return;
|
||||
}
|
||||
|
||||
peer.write(payload);
|
||||
await waitForPeerBufferToClear(peer);
|
||||
},
|
||||
destroy() {
|
||||
peer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -118,5 +118,6 @@ test('parseTransferControlMessage returns null for invalid payloads', () => {
|
||||
|
||||
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([4, 5, 6]))), [4, 5, 6]);
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
|
||||
});
|
||||
|
||||
52
front/src/lib/transfer-runtime.test.ts
Normal file
52
front/src/lib/transfer-runtime.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
SAFE_TRANSFER_CHUNK_SIZE,
|
||||
TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from './transfer-runtime';
|
||||
|
||||
test('resolveTransferChunkSize prefers a conservative default across browsers', () => {
|
||||
assert.equal(SAFE_TRANSFER_CHUNK_SIZE, 64 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(undefined), 64 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(8 * 1024), 8 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(256 * 1024), 64 * 1024);
|
||||
});
|
||||
|
||||
test('shouldPublishTransferProgress throttles noisy intermediate updates but always allows forward progress after the interval', () => {
|
||||
const initialTime = 10_000;
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 1,
|
||||
previousProgress: 0,
|
||||
now: initialTime,
|
||||
lastPublishedAt: initialTime,
|
||||
}), false);
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 1,
|
||||
previousProgress: 0,
|
||||
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
|
||||
lastPublishedAt: initialTime,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('shouldPublishTransferProgress always allows terminal or changed progress states through immediately', () => {
|
||||
const initialTime = 10_000;
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 100,
|
||||
previousProgress: 99,
|
||||
now: initialTime,
|
||||
lastPublishedAt: initialTime,
|
||||
}), true);
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 30,
|
||||
previousProgress: 30,
|
||||
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS * 10,
|
||||
lastPublishedAt: initialTime,
|
||||
}), false);
|
||||
});
|
||||
@@ -1,19 +1,30 @@
|
||||
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
|
||||
export const SAFE_TRANSFER_CHUNK_SIZE = 64 * 1024;
|
||||
export const MAX_TRANSFER_CHUNK_SIZE = 64 * 1024;
|
||||
export const TRANSFER_PROGRESS_UPDATE_INTERVAL_MS = 120;
|
||||
|
||||
export async function waitForTransferChannelDrain(
|
||||
channel: RTCDataChannel,
|
||||
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
|
||||
) {
|
||||
if (channel.bufferedAmount <= maxBufferedAmount) {
|
||||
return;
|
||||
export function resolveTransferChunkSize(maxMessageSize?: number | null) {
|
||||
if (!Number.isFinite(maxMessageSize) || !maxMessageSize || maxMessageSize <= 0) {
|
||||
return SAFE_TRANSFER_CHUNK_SIZE;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
|
||||
window.clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 40);
|
||||
});
|
||||
return Math.max(1024, Math.min(maxMessageSize, MAX_TRANSFER_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
export function shouldPublishTransferProgress(params: {
|
||||
nextProgress: number;
|
||||
previousProgress: number;
|
||||
now: number;
|
||||
lastPublishedAt: number;
|
||||
}) {
|
||||
const { nextProgress, previousProgress, now, lastPublishedAt } = params;
|
||||
|
||||
if (nextProgress === previousProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextProgress >= 100 || nextProgress <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return now - lastPublishedAt >= TRANSFER_PROGRESS_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
flushPendingRemoteIceCandidates,
|
||||
handleRemoteIceCandidate,
|
||||
} from './transfer-signaling';
|
||||
|
||||
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
|
||||
const appliedCandidates: RTCIceCandidateInit[] = [];
|
||||
const connection = {
|
||||
remoteDescription: null,
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
appliedCandidates.push(candidate);
|
||||
},
|
||||
};
|
||||
const candidate: RTCIceCandidateInit = {
|
||||
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
};
|
||||
|
||||
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
|
||||
|
||||
assert.deepEqual(appliedCandidates, []);
|
||||
assert.deepEqual(pendingCandidates, [candidate]);
|
||||
});
|
||||
|
||||
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
|
||||
const appliedCandidates: RTCIceCandidateInit[] = [];
|
||||
const connection = {
|
||||
remoteDescription: { type: 'answer' } as RTCSessionDescription,
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
appliedCandidates.push(candidate);
|
||||
},
|
||||
};
|
||||
const pendingCandidates: RTCIceCandidateInit[] = [
|
||||
{
|
||||
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
},
|
||||
{
|
||||
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
|
||||
|
||||
assert.deepEqual(appliedCandidates, pendingCandidates);
|
||||
assert.deepEqual(remainingCandidates, []);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
interface RemoteIceCapableConnection {
|
||||
remoteDescription: RTCSessionDescription | null;
|
||||
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
||||
}
|
||||
|
||||
export async function handleRemoteIceCandidate(
|
||||
connection: RemoteIceCapableConnection,
|
||||
pendingCandidates: RTCIceCandidateInit[],
|
||||
candidate: RTCIceCandidateInit,
|
||||
) {
|
||||
if (!connection.remoteDescription) {
|
||||
return [...pendingCandidates, candidate];
|
||||
}
|
||||
|
||||
await connection.addIceCandidate(candidate);
|
||||
return pendingCandidates;
|
||||
}
|
||||
|
||||
export async function flushPendingRemoteIceCandidates(
|
||||
connection: RemoteIceCapableConnection,
|
||||
pendingCandidates: RTCIceCandidateInit[],
|
||||
) {
|
||||
if (!connection.remoteDescription || pendingCandidates.length === 0) {
|
||||
return pendingCandidates;
|
||||
}
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
await connection.addIceCandidate(candidate);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { afterEach, test } from 'node:test';
|
||||
|
||||
import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer';
|
||||
|
||||
const originalLocation = globalThis.location;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test('toTransferFilePayload keeps relative folder paths for transfer files', () => {
|
||||
const report = new File(['hello'], 'report.pdf', {
|
||||
type: 'application/pdf',
|
||||
@@ -28,3 +37,27 @@ test('buildOfflineTransferDownloadUrl points to the public offline download endp
|
||||
'/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor localhost shell', () => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('http://localhost'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
|
||||
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor https localhost shell', () => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://localhost'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
|
||||
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FileMetadata, TransferMode } from './types';
|
||||
import { apiRequest } from './api';
|
||||
import { apiUploadRequest } from './api';
|
||||
import { apiRequest, apiUploadRequest, getApiBaseUrl } from './api';
|
||||
import { hasRelayTransferIceServer, resolveTransferIceServers } from './transfer-ice';
|
||||
import { getTransferFileRelativePath } from './transfer-protocol';
|
||||
import type {
|
||||
LookupTransferSessionResponse,
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
TransferSessionResponse,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
|
||||
{urls: 'stun:stun.cloudflare.com:3478'},
|
||||
{urls: 'stun:stun.l.google.com:19302'},
|
||||
];
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS = resolveTransferIceServers();
|
||||
export const TRANSFER_HAS_RELAY_SUPPORT = hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
|
||||
export function toTransferFilePayload(files: File[]) {
|
||||
return files.map((file) => ({
|
||||
@@ -64,8 +62,7 @@ export function uploadOfflineTransferFile(
|
||||
}
|
||||
|
||||
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
|
||||
const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
}
|
||||
|
||||
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface AdminRequestTimelinePoint {
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyActiveUserSummary {
|
||||
metricDate: string;
|
||||
label: string;
|
||||
userCount: number;
|
||||
usernames: string[];
|
||||
}
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
@@ -30,6 +37,7 @@ export interface AdminSummary {
|
||||
transferUsageBytes: number;
|
||||
offlineTransferStorageBytes: number;
|
||||
offlineTransferStorageLimitBytes: number;
|
||||
dailyActiveUsers: AdminDailyActiveUserSummary[];
|
||||
requestTimeline: AdminRequestTimelinePoint[];
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user