Fix Android WebView API access and mobile shell layout

This commit is contained in:
yoyuzh
2026-04-03 14:37:21 +08:00
parent f02ff9342f
commit 56f2a9fe0d
121 changed files with 4751 additions and 700 deletions

View File

@@ -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(

View File

@@ -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) {

View 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);
});

View 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];
}

View 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']);
});

View 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();
},
};
}

View File

@@ -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]);
});

View 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);
});

View File

@@ -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;
}

View File

@@ -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, []);
});

View File

@@ -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 [];
}

View File

@@ -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',
);
});

View File

@@ -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) {

View File

@@ -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;
}