Fix Android WebView API access and mobile shell layout
This commit is contained in:
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildRequestLineChartModel,
|
||||
buildRequestLineChartXAxisPoints,
|
||||
formatMetricValue,
|
||||
getInviteCodePanelState,
|
||||
parseStorageLimitInput,
|
||||
@@ -19,6 +20,7 @@ test('getInviteCodePanelState returns a copyable invite code when summary contai
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: ' AbCd1234 ',
|
||||
}),
|
||||
@@ -40,6 +42,7 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: ' ',
|
||||
}),
|
||||
@@ -87,3 +90,38 @@ test('buildRequestLineChartModel converts hourly request data into chart coordin
|
||||
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
|
||||
assert.equal(model.peakPoint?.label, '02:00');
|
||||
});
|
||||
|
||||
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
|
||||
const model = buildRequestLineChartModel([
|
||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||
]);
|
||||
|
||||
assert.equal(model.points[0]?.x, 0);
|
||||
assert.equal(model.points.at(-1)?.x, 100);
|
||||
assert.equal(model.points.length, 8);
|
||||
});
|
||||
|
||||
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
|
||||
const model = buildRequestLineChartModel([
|
||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
|
||||
['00:00', '06:00', '07:00'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface RequestLineChartModel {
|
||||
type MetricValueKind = 'bytes' | 'count';
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
|
||||
|
||||
export function formatMetricValue(value: number, kind: MetricValueKind): string {
|
||||
if (kind === 'count') {
|
||||
@@ -103,6 +104,23 @@ export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
|
||||
if (points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstHour = points[0]?.hour ?? 0;
|
||||
const lastHour = points.at(-1)?.hour ?? firstHour;
|
||||
const visibleHours = new Set<number>([firstHour, lastHour]);
|
||||
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
|
||||
if (hour > firstHour && hour < lastHour) {
|
||||
visibleHours.add(hour);
|
||||
}
|
||||
}
|
||||
|
||||
return points.filter((point) => visibleHours.has(point.hour));
|
||||
}
|
||||
|
||||
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
||||
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
||||
if (!inviteCode) {
|
||||
|
||||
@@ -14,7 +14,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { readStoredSession } from '@/src/lib/session';
|
||||
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
|
||||
import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
|
||||
import {
|
||||
buildRequestLineChartModel,
|
||||
buildRequestLineChartXAxisPoints,
|
||||
formatMetricValue,
|
||||
getInviteCodePanelState,
|
||||
parseStorageLimitInput,
|
||||
} from './dashboard-state';
|
||||
|
||||
interface DashboardState {
|
||||
summary: AdminSummary | null;
|
||||
@@ -30,7 +36,6 @@ interface MetricCardDefinition {
|
||||
helper: string;
|
||||
}
|
||||
|
||||
const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]);
|
||||
const DASHBOARD_CARD_BG = '#111827';
|
||||
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
|
||||
const DASHBOARD_CARD_TEXT = '#f8fafc';
|
||||
@@ -129,7 +134,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
const chart = buildRequestLineChartModel(summary.requestTimeline);
|
||||
const currentHour = new Date().getHours();
|
||||
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
|
||||
const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour));
|
||||
const xAxisPoints = buildRequestLineChartXAxisPoints(chart.points);
|
||||
const hasRequests = chart.maxValue > 0;
|
||||
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
|
||||
|
||||
@@ -161,7 +166,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。
|
||||
按小时统计今天已发生的 `/api/**` 请求;曲线会随当天已过时间自然拉长,不再预留未来小时。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -340,21 +345,26 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((point) => (
|
||||
<circle
|
||||
key={point.label}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
|
||||
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{chart.points.map((point) => (
|
||||
<Box
|
||||
key={point.label}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`,
|
||||
width: point.hour === currentPoint?.hour ? 8 : 6,
|
||||
height: point.hour === currentPoint?.hour ? 8 : 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!hasRequests && (
|
||||
<Stack
|
||||
spacing={0.4}
|
||||
@@ -405,6 +415,141 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DailyActiveUsersCard({ summary }: { summary: AdminSummary }) {
|
||||
const latestDay = summary.dailyActiveUsers.at(-1) ?? null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
|
||||
})}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.5}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
>
|
||||
<Stack spacing={0.75}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
最近 7 天上线记录
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
JWT 鉴权成功后会记录当天首次上线用户,只保留最近 7 天,便于回看每天有多少人上线以及具体是谁。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
spacing={0.35}
|
||||
sx={{
|
||||
minWidth: 156,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(16, 185, 129, 0.12)' : '#ecfdf5',
|
||||
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(52, 211, 153, 0.18)' : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
fontWeight={700}
|
||||
>
|
||||
今日上线人数
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={800}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(latestDay?.userCount ?? 0, 'count')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{latestDay?.label ?? '--'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
{summary.dailyActiveUsers.slice().reverse().map((day) => (
|
||||
<Box
|
||||
key={day.metricDate}
|
||||
sx={(theme) => ({
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.03)' : '#f8fafc',
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.25}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography fontWeight={700}>{day.label}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
px: 0.9,
|
||||
py: 0.3,
|
||||
borderRadius: 99,
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(59, 130, 246, 0.18)' : '#dbeafe',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(day.userCount, 'count')} 人
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{day.metricDate}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{day.usernames.length > 0 ? day.usernames.join('、') : '当天无人上线'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalAdminDashboard() {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
summary: null,
|
||||
@@ -586,7 +731,12 @@ export function PortalAdminDashboard() {
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{summary && <RequestTrendChart summary={summary} />}
|
||||
{summary && (
|
||||
<Stack spacing={2}>
|
||||
<RequestTrendChart summary={summary} />
|
||||
<DailyActiveUsersCard summary={summary} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
@@ -19,12 +19,29 @@
|
||||
--color-glass-active: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:root {
|
||||
--app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
|
||||
--app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--color-bg-base);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
@@ -59,6 +76,14 @@ body {
|
||||
background: var(--color-glass-active);
|
||||
}
|
||||
|
||||
.safe-area-pt {
|
||||
padding-top: var(--app-safe-area-top);
|
||||
}
|
||||
|
||||
.safe-area-pb {
|
||||
padding-bottom: var(--app-safe-area-bottom);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes blob {
|
||||
0% {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getVisibleNavItems } from './MobileLayout';
|
||||
import {
|
||||
getMobileViewportOffsetClassNames,
|
||||
getVisibleNavItems,
|
||||
isNativeMobileShellLocation,
|
||||
} from './MobileLayout';
|
||||
|
||||
test('mobile navigation hides the games entry', () => {
|
||||
const visiblePaths = getVisibleNavItems(false).map((item) => item.path as string);
|
||||
@@ -9,3 +13,23 @@ test('mobile navigation hides the games entry', () => {
|
||||
assert.equal(visiblePaths.includes('/games'), false);
|
||||
assert.deepEqual(visiblePaths, ['/overview', '/files', '/transfer']);
|
||||
});
|
||||
|
||||
test('mobile layout reserves top safe-area space for the fixed app bar', () => {
|
||||
const offsets = getMobileViewportOffsetClassNames();
|
||||
|
||||
assert.match(offsets.header, /\bsafe-area-pt\b/);
|
||||
assert.match(offsets.main, /var\(--app-safe-area-top\)/);
|
||||
});
|
||||
|
||||
test('mobile layout adds extra top spacing inside the native shell', () => {
|
||||
const offsets = getMobileViewportOffsetClassNames(true);
|
||||
|
||||
assert.match(offsets.header, /\bpt-6\b/);
|
||||
assert.match(offsets.main, /1\.5rem/);
|
||||
});
|
||||
|
||||
test('native mobile shell detection matches Capacitor localhost origins', () => {
|
||||
assert.equal(isNativeMobileShellLocation(new URL('https://localhost')), true);
|
||||
assert.equal(isNativeMobileShellLocation(new URL('http://127.0.0.1')), true);
|
||||
assert.equal(isNativeMobileShellLocation(new URL('https://yoyuzh.xyz')), false);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,33 @@ const NAV_ITEMS = [
|
||||
{ name: '快传', path: '/transfer', icon: Send },
|
||||
] as const;
|
||||
|
||||
export function isNativeMobileShellLocation(location: Location | URL | null) {
|
||||
if (!location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = location.hostname || '';
|
||||
const protocol = location.protocol || '';
|
||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:';
|
||||
|
||||
return isLocalhostHost && isCapacitorScheme;
|
||||
}
|
||||
|
||||
export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
||||
if (isNativeShell) {
|
||||
return {
|
||||
header: 'safe-area-pt pt-6',
|
||||
main: 'pt-[calc(3.5rem+1.5rem+var(--app-safe-area-top))]',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
header: 'safe-area-pt',
|
||||
main: 'pt-[calc(3.5rem+var(--app-safe-area-top))]',
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveModal = 'security' | 'settings' | null;
|
||||
|
||||
export function getVisibleNavItems(isAdmin: boolean) {
|
||||
@@ -47,6 +74,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||
const navItems = getVisibleNavItems(isAdmin);
|
||||
const viewportOffsets = getMobileViewportOffsetClassNames(
|
||||
typeof window !== 'undefined' && isNativeMobileShellLocation(window.location),
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
@@ -234,7 +264,7 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Top App Bar */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 w-full glass-panel border-b border-white/5 bg-[#07101D]/70 backdrop-blur-2xl">
|
||||
<header className={cn("fixed top-0 left-0 right-0 z-40 w-full glass-panel border-b border-white/5 bg-[#07101D]/70 backdrop-blur-2xl", viewportOffsets.header)}>
|
||||
<div className="flex items-center justify-between px-4 h-14">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg">
|
||||
@@ -306,7 +336,7 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 w-full overflow-y-auto overflow-x-hidden pt-14 pb-16 z-10">
|
||||
<main className={cn("flex-1 w-full overflow-y-auto overflow-x-hidden pb-16 z-10", viewportOffsets.main)}>
|
||||
{children ?? <Outlet />}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/src/auth/AuthProvider';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
|
||||
import {
|
||||
createTransferFileManifest,
|
||||
@@ -35,12 +36,15 @@ import {
|
||||
createTransferFileMetaMessage,
|
||||
type TransferFileDescriptor,
|
||||
SIGNAL_POLL_INTERVAL_MS,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import {
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
createTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
pollTransferSignals,
|
||||
@@ -92,7 +96,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
||||
|
||||
export default function MobileTransfer() {
|
||||
const navigate = useNavigate();
|
||||
const { session: authSession } = useAuth();
|
||||
const { ready: authReady, session: authSession } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get('session');
|
||||
const isAuthenticated = Boolean(authSession?.token);
|
||||
@@ -118,14 +122,14 @@ export default function MobileTransfer() {
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const bootstrapIdRef = useRef(0);
|
||||
const totalBytesRef = useRef(0);
|
||||
const sentBytesRef = useRef(0);
|
||||
const lastSendProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedSendProgressRef = useRef(0);
|
||||
const sendingStartedRef = useRef(false);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const manifestRef = useRef<TransferFileDescriptor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -193,14 +197,30 @@ export default function MobileTransfer() {
|
||||
|
||||
function cleanupCurrentTransfer() {
|
||||
if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; }
|
||||
if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; }
|
||||
cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = [];
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
cursorRef.current = 0; lastSendProgressPublishAtRef.current = 0; lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false;
|
||||
}
|
||||
|
||||
function publishSendProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedSendProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastSendProgressPublishAtRef.current,
|
||||
})) return;
|
||||
|
||||
lastSendProgressPublishAtRef.current = now;
|
||||
lastPublishedSendProgressRef.current = normalizedProgress;
|
||||
setSendProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function resetSenderState() {
|
||||
cleanupCurrentTransfer();
|
||||
setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError('');
|
||||
setSession(null); setSelectedFiles([]); setSendPhase('idle'); publishSendProgress(0, {force: true}); setSendError('');
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
@@ -236,7 +256,7 @@ export default function MobileTransfer() {
|
||||
bootstrapIdRef.current = bootstrapId;
|
||||
|
||||
cleanupCurrentTransfer();
|
||||
setSendError(''); setSendPhase('creating'); setSendProgress(0);
|
||||
setSendError(''); setSendPhase('creating'); publishSendProgress(0, {force: true});
|
||||
manifestRef.current = createTransferFileManifest(files);
|
||||
totalBytesRef.current = 0; sentBytesRef.current = 0;
|
||||
|
||||
@@ -261,7 +281,7 @@ export default function MobileTransfer() {
|
||||
|
||||
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
setSendPhase('uploading');
|
||||
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true});
|
||||
for (const [idx, file] of files.entries()) {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
const sessionFile = createdSession.files[idx];
|
||||
@@ -271,55 +291,61 @@ export default function MobileTransfer() {
|
||||
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
|
||||
sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded;
|
||||
if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
|
||||
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
});
|
||||
}
|
||||
setSendProgress(100); setSendPhase('completed');
|
||||
publishSendProgress(100, {force: true}); setSendPhase('completed');
|
||||
void loadOfflineHistory({silent: true});
|
||||
}
|
||||
|
||||
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS });
|
||||
const channel = conn.createDataChannel('portal-transfer', { ordered: true });
|
||||
peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer';
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||||
peer.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
},
|
||||
onData: (payload) => {
|
||||
if (typeof payload !== 'string') return;
|
||||
const msg = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
|
||||
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
|
||||
|
||||
conn.onicecandidate = (e) => {
|
||||
if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON()));
|
||||
};
|
||||
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) return;
|
||||
|
||||
conn.onconnectionstatechange = () => {
|
||||
if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||||
if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); }
|
||||
};
|
||||
|
||||
channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
channel.onmessage = (e) => {
|
||||
if (typeof e.data !== 'string') return;
|
||||
const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data);
|
||||
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) return;
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
|
||||
};
|
||||
channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); };
|
||||
startSenderPolling(createdSession.sessionId, conn, bootstrapId);
|
||||
|
||||
const offer = await conn.createOffer();
|
||||
await conn.setLocalDescription(offer);
|
||||
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true});
|
||||
void sendSelectedFiles(peer, files, requestedFiles, bootstrapId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
setSendPhase('error');
|
||||
setSendError(appendTransferRelayHint(
|
||||
error.message || '数据通道建立失败',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startSenderPolling(createdSession.sessionId, bootstrapId);
|
||||
}
|
||||
|
||||
function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) {
|
||||
function startSenderPolling(sessionId: string, bootstrapId: number) {
|
||||
let polling = false;
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
if (polling || bootstrapIdRef.current !== bootstrapId) return;
|
||||
polling = true;
|
||||
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
|
||||
.then(async (res) => {
|
||||
.then((res) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
cursorRef.current = res.nextCursor;
|
||||
for (const item of res.items) {
|
||||
@@ -327,17 +353,8 @@ export default function MobileTransfer() {
|
||||
setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur));
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'answer' && !conn.currentRemoteDescription) {
|
||||
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (answer) {
|
||||
await conn.setRemoteDescription(answer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'ice-candidate') {
|
||||
const cand = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand);
|
||||
if (item.type === 'signal') {
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -349,28 +366,33 @@ export default function MobileTransfer() {
|
||||
}, SIGNAL_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) {
|
||||
async function sendSelectedFiles(
|
||||
peer: TransferPeerAdapter,
|
||||
files: File[],
|
||||
requestedFiles: TransferFileDescriptor[],
|
||||
bootstrapId: number,
|
||||
) {
|
||||
setSendPhase('transferring');
|
||||
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
|
||||
const chunkSize = resolveTransferChunkSize();
|
||||
|
||||
for (const desc of requestedFiles) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return;
|
||||
const file = filesById.get(desc.id);
|
||||
if (!file) continue;
|
||||
|
||||
channel.send(createTransferFileMetaMessage(desc));
|
||||
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
|
||||
await waitForTransferChannelDrain(channel);
|
||||
channel.send(chunk);
|
||||
peer.send(createTransferFileMetaMessage(desc));
|
||||
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return;
|
||||
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
|
||||
await peer.write(chunk);
|
||||
sentBytesRef.current += chunk.byteLength;
|
||||
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
channel.send(createTransferFileCompleteMessage(desc.id));
|
||||
peer.send(createTransferFileCompleteMessage(desc.id));
|
||||
}
|
||||
channel.send(createTransferCompleteMessage());
|
||||
setSendProgress(100); setSendPhase('completed');
|
||||
peer.send(createTransferCompleteMessage());
|
||||
publishSendProgress(100, {force: true}); setSendPhase('completed');
|
||||
}
|
||||
|
||||
async function copyOfflineSessionLink(s: TransferSessionResponse) {
|
||||
@@ -423,9 +445,9 @@ export default function MobileTransfer() {
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
|
||||
{!isAuthenticated && (
|
||||
{authReady && !isAuthenticated && (
|
||||
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
|
||||
<p className="leading-relaxed">无需登录仅支持在线模式。离线模式可保留文件7天,需登录后可用。</p>
|
||||
<p className="leading-relaxed">无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。</p>
|
||||
<Button variant="outline" size="sm" onClick={navigateBackToLogin} className="w-full bg-white/5 border-white/10 text-white mt-1">
|
||||
<LogIn className="mr-2 h-3.5 w-3.5" /> 去登录
|
||||
</Button>
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
hasLoadedDirectoryListing,
|
||||
mergeDirectoryChildren,
|
||||
toDirectoryPath,
|
||||
type DirectoryChildrenMap,
|
||||
@@ -349,7 +350,7 @@ export default function Files() {
|
||||
}
|
||||
|
||||
next.add(path);
|
||||
shouldLoadChildren = !(path in directoryChildren);
|
||||
shouldLoadChildren = !hasLoadedDirectoryListing(pathParts, loadedDirectoryPaths);
|
||||
return next;
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/src/auth/AuthProvider';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
|
||||
import {
|
||||
createTransferFileManifest,
|
||||
@@ -35,12 +36,15 @@ import {
|
||||
createTransferFileMetaMessage,
|
||||
type TransferFileDescriptor,
|
||||
SIGNAL_POLL_INTERVAL_MS,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import {
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
createTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
pollTransferSignals,
|
||||
@@ -108,7 +112,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
||||
|
||||
export default function Transfer() {
|
||||
const navigate = useNavigate();
|
||||
const { session: authSession } = useAuth();
|
||||
const { ready: authReady, session: authSession } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get('session');
|
||||
const isAuthenticated = Boolean(authSession?.token);
|
||||
@@ -134,14 +138,14 @@ export default function Transfer() {
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const bootstrapIdRef = useRef(0);
|
||||
const totalBytesRef = useRef(0);
|
||||
const sentBytesRef = useRef(0);
|
||||
const lastSendProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedSendProgressRef = useRef(0);
|
||||
const sendingStartedRef = useRef(false);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const manifestRef = useRef<TransferFileDescriptor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,19 +256,31 @@ export default function Transfer() {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (dataChannelRef.current) {
|
||||
dataChannelRef.current.close();
|
||||
dataChannelRef.current = null;
|
||||
}
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
peerConnectionRef.current.close();
|
||||
peerConnectionRef.current = null;
|
||||
}
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
|
||||
cursorRef.current = 0;
|
||||
lastSendProgressPublishAtRef.current = 0;
|
||||
lastPublishedSendProgressRef.current = 0;
|
||||
sendingStartedRef.current = false;
|
||||
pendingRemoteCandidatesRef.current = [];
|
||||
}
|
||||
|
||||
function publishSendProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedSendProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastSendProgressPublishAtRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSendProgressPublishAtRef.current = now;
|
||||
lastPublishedSendProgressRef.current = normalizedProgress;
|
||||
setSendProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function resetSenderState() {
|
||||
@@ -272,7 +288,7 @@ export default function Transfer() {
|
||||
setSession(null);
|
||||
setSelectedFiles([]);
|
||||
setSendPhase('idle');
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
setSendError('');
|
||||
}
|
||||
|
||||
@@ -334,7 +350,7 @@ export default function Transfer() {
|
||||
cleanupCurrentTransfer();
|
||||
setSendError('');
|
||||
setSendPhase('creating');
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
manifestRef.current = createTransferFileManifest(files);
|
||||
totalBytesRef.current = 0;
|
||||
sentBytesRef.current = 0;
|
||||
@@ -367,7 +383,7 @@ export default function Transfer() {
|
||||
setSendPhase('uploading');
|
||||
totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
@@ -390,95 +406,71 @@ export default function Transfer() {
|
||||
}
|
||||
|
||||
if (totalBytesRef.current > 0) {
|
||||
setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSendProgress(100);
|
||||
publishSendProgress(100, {force: true});
|
||||
setSendPhase('completed');
|
||||
void loadOfflineHistory({silent: true});
|
||||
}
|
||||
|
||||
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
});
|
||||
const channel = connection.createDataChannel('portal-transfer', {
|
||||
ordered: true,
|
||||
});
|
||||
|
||||
peerConnectionRef.current = connection;
|
||||
dataChannelRef.current = channel;
|
||||
channel.binaryType = 'arraybuffer';
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
void postTransferSignal(
|
||||
createdSession.sessionId,
|
||||
'sender',
|
||||
'ice-candidate',
|
||||
JSON.stringify(event.candidate.toJSON()),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (connection.connectionState === 'connected') {
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
return;
|
||||
}
|
||||
setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting'));
|
||||
}
|
||||
peer.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
},
|
||||
onData: (payload) => {
|
||||
if (typeof payload !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
|
||||
const message = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
|
||||
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds) || sendingStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
publishSendProgress(0, {force: true});
|
||||
void sendSelectedFiles(peer, files, requestedFiles, bootstrapId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
return;
|
||||
}
|
||||
setSendPhase('error');
|
||||
setSendError('浏览器直连失败,请重新生成分享链接再试一次。');
|
||||
}
|
||||
};
|
||||
|
||||
channel.onopen = () => {
|
||||
channel.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data);
|
||||
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendingStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
setSendProgress(0);
|
||||
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
|
||||
};
|
||||
|
||||
channel.onerror = () => {
|
||||
setSendPhase('error');
|
||||
setSendError('数据通道建立失败,请重新开始本次快传。');
|
||||
};
|
||||
|
||||
startSenderPolling(createdSession.sessionId, connection, bootstrapId);
|
||||
|
||||
const offer = await connection.createOffer();
|
||||
await connection.setLocalDescription(offer);
|
||||
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
|
||||
setSendError(appendTransferRelayHint(
|
||||
error.message || '数据通道建立失败,请重新开始本次快传。',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startSenderPolling(createdSession.sessionId, bootstrapId);
|
||||
}
|
||||
|
||||
function startSenderPolling(sessionId: string, connection: RTCPeerConnection, bootstrapId: number) {
|
||||
function startSenderPolling(sessionId: string, bootstrapId: number) {
|
||||
let polling = false;
|
||||
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
@@ -502,27 +494,8 @@ export default function Transfer() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'answer' && !connection.currentRemoteDescription) {
|
||||
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (answer) {
|
||||
await connection.setRemoteDescription(answer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'ice-candidate') {
|
||||
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (candidate) {
|
||||
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
candidate,
|
||||
);
|
||||
}
|
||||
if (item.type === 'signal') {
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -540,16 +513,17 @@ export default function Transfer() {
|
||||
}
|
||||
|
||||
async function sendSelectedFiles(
|
||||
channel: RTCDataChannel,
|
||||
peer: TransferPeerAdapter,
|
||||
files: File[],
|
||||
requestedFiles: TransferFileDescriptor[],
|
||||
bootstrapId: number,
|
||||
) {
|
||||
setSendPhase('transferring');
|
||||
const filesById = new Map(files.map((file) => [createTransferFileId(file), file]));
|
||||
const chunkSize = resolveTransferChunkSize();
|
||||
|
||||
for (const descriptor of requestedFiles) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,31 +532,30 @@ export default function Transfer() {
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.send(createTransferFileMetaMessage(descriptor));
|
||||
peer.send(createTransferFileMetaMessage(descriptor));
|
||||
|
||||
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
|
||||
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
|
||||
await waitForTransferChannelDrain(channel);
|
||||
channel.send(chunk);
|
||||
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
|
||||
await peer.write(chunk);
|
||||
sentBytesRef.current += chunk.byteLength;
|
||||
|
||||
if (totalBytesRef.current > 0) {
|
||||
setSendProgress(Math.min(
|
||||
publishSendProgress(Math.min(
|
||||
99,
|
||||
Math.round((sentBytesRef.current / totalBytesRef.current) * 100),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
channel.send(createTransferFileCompleteMessage(descriptor.id));
|
||||
peer.send(createTransferFileCompleteMessage(descriptor.id));
|
||||
}
|
||||
|
||||
channel.send(createTransferCompleteMessage());
|
||||
setSendProgress(100);
|
||||
peer.send(createTransferCompleteMessage());
|
||||
publishSendProgress(100, {force: true});
|
||||
setSendPhase('completed');
|
||||
}
|
||||
|
||||
@@ -650,10 +623,10 @@ export default function Transfer() {
|
||||
) : null}
|
||||
|
||||
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
|
||||
{!isAuthenticated ? (
|
||||
{authReady && !isAuthenticated ? (
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-blue-400/15 bg-blue-500/10 px-4 py-4 text-sm text-blue-100 md:flex-row md:items-center md:justify-between">
|
||||
<p className="leading-6">
|
||||
当前无需登录即可使用快传,但仅支持在线发送和在线接收。离线快传仍需登录后使用。
|
||||
当前无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
DownloadCloud,
|
||||
@@ -17,6 +18,7 @@ import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerMod
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Input } from '@/src/components/ui/input';
|
||||
import { buildTransferArchiveFileName, createTransferZipArchive } from '@/src/lib/transfer-archive';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { resolveNetdiskSaveDirectory, saveFileToNetdisk } from '@/src/lib/netdisk-upload';
|
||||
import {
|
||||
createTransferReceiveRequestMessage,
|
||||
@@ -25,10 +27,12 @@ import {
|
||||
toTransferChunk,
|
||||
type TransferFileDescriptor,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import { shouldPublishTransferProgress } from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
buildOfflineTransferDownloadUrl,
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
importOfflineTransferFile,
|
||||
joinTransferSession,
|
||||
lookupTransferSession,
|
||||
@@ -37,7 +41,13 @@ import {
|
||||
} from '@/src/lib/transfer';
|
||||
import type { TransferSessionResponse } from '@/src/lib/types';
|
||||
|
||||
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
|
||||
import {
|
||||
buildTransferReceiveSearchParams,
|
||||
canSubmitReceiveCodeLookupOnEnter,
|
||||
canArchiveTransferSelection,
|
||||
formatTransferSize,
|
||||
sanitizeReceiveCode,
|
||||
} from './transfer-state';
|
||||
|
||||
type ReceivePhase = 'idle' | 'joining' | 'waiting' | 'connecting' | 'receiving' | 'completed' | 'error';
|
||||
|
||||
@@ -54,14 +64,6 @@ interface IncomingTransferFile extends TransferFileDescriptor {
|
||||
receivedBytes: number;
|
||||
}
|
||||
|
||||
function parseJsonPayload<T>(payload: string): T | null {
|
||||
try {
|
||||
return JSON.parse(payload) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TransferReceiveProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
@@ -85,17 +87,19 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
const [savePathPickerFileId, setSavePathPickerFileId] = useState<string | null>(null);
|
||||
const [saveRootPath, setSaveRootPath] = useState('/下载');
|
||||
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const lifecycleIdRef = useRef(0);
|
||||
const currentFileIdRef = useRef<string | null>(null);
|
||||
const totalBytesRef = useRef(0);
|
||||
const receivedBytesRef = useRef(0);
|
||||
const lastOverallProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedOverallProgressRef = useRef(0);
|
||||
const lastFileProgressPublishAtRef = useRef(new Map<string, number>());
|
||||
const lastPublishedFileProgressRef = useRef(new Map<string, number>());
|
||||
const downloadUrlsRef = useRef<string[]>([]);
|
||||
const requestedFileIdsRef = useRef<string[]>([]);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const archiveBuiltRef = useRef(false);
|
||||
const completedFilesRef = useRef(new Map<string, {
|
||||
name: string;
|
||||
@@ -117,7 +121,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setTransferSession(null);
|
||||
setFiles([]);
|
||||
setPhase('idle');
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveUrl(null);
|
||||
@@ -133,15 +137,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (dataChannelRef.current) {
|
||||
dataChannelRef.current.close();
|
||||
dataChannelRef.current = null;
|
||||
}
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
peerConnectionRef.current.close();
|
||||
peerConnectionRef.current = null;
|
||||
}
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
|
||||
for (const url of downloadUrlsRef.current) {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -153,11 +151,50 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
cursorRef.current = 0;
|
||||
receivedBytesRef.current = 0;
|
||||
totalBytesRef.current = 0;
|
||||
lastOverallProgressPublishAtRef.current = 0;
|
||||
lastPublishedOverallProgressRef.current = 0;
|
||||
lastFileProgressPublishAtRef.current.clear();
|
||||
lastPublishedFileProgressRef.current.clear();
|
||||
requestedFileIdsRef.current = [];
|
||||
pendingRemoteCandidatesRef.current = [];
|
||||
archiveBuiltRef.current = false;
|
||||
}
|
||||
|
||||
function publishOverallProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedOverallProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastOverallProgressPublishAtRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastOverallProgressPublishAtRef.current = now;
|
||||
lastPublishedOverallProgressRef.current = normalizedProgress;
|
||||
setOverallProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function shouldPublishFileProgress(fileId: string, nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
const previousProgress = lastPublishedFileProgressRef.current.get(fileId) ?? 0;
|
||||
const lastPublishedAt = lastFileProgressPublishAtRef.current.get(fileId) ?? 0;
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress,
|
||||
now,
|
||||
lastPublishedAt,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastFileProgressPublishAtRef.current.set(fileId, now);
|
||||
lastPublishedFileProgressRef.current.set(fileId, normalizedProgress);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReceivingSession(sessionId: string) {
|
||||
const lifecycleId = lifecycleIdRef.current + 1;
|
||||
lifecycleIdRef.current = lifecycleId;
|
||||
@@ -166,7 +203,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setPhase('joining');
|
||||
setErrorMessage('');
|
||||
setFiles([]);
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveName(buildTransferArchiveFileName('快传文件'));
|
||||
@@ -199,53 +236,43 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
setFiles(offlineFiles);
|
||||
setRequestSubmitted(true);
|
||||
setOverallProgress(offlineFiles.length > 0 ? 100 : 0);
|
||||
publishOverallProgress(offlineFiles.length > 0 ? 100 : 0, {force: true});
|
||||
setPhase('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
});
|
||||
peerConnectionRef.current = connection;
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
void postTransferSignal(
|
||||
joinedSession.sessionId,
|
||||
'receiver',
|
||||
'ice-candidate',
|
||||
JSON.stringify(event.candidate.toJSON()),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (connection.connectionState === 'connected') {
|
||||
const peer = createTransferPeer({
|
||||
initiator: false,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(joinedSession.sessionId, 'receiver', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
return;
|
||||
}
|
||||
setPhase((current) => (current === 'completed' ? current : 'connecting'));
|
||||
}
|
||||
|
||||
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
|
||||
},
|
||||
onData: (payload) => {
|
||||
void handleIncomingMessage(payload);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
return;
|
||||
}
|
||||
setPhase('error');
|
||||
setErrorMessage('浏览器之间的直连失败,请重新打开分享链接。');
|
||||
}
|
||||
};
|
||||
|
||||
connection.ondatachannel = (event) => {
|
||||
const channel = event.channel;
|
||||
dataChannelRef.current = channel;
|
||||
channel.binaryType = 'arraybuffer';
|
||||
channel.onopen = () => {
|
||||
setPhase((current) => (current === 'completed' ? current : 'connecting'));
|
||||
};
|
||||
channel.onmessage = (messageEvent) => {
|
||||
void handleIncomingMessage(messageEvent.data);
|
||||
};
|
||||
};
|
||||
|
||||
startReceiverPolling(joinedSession.sessionId, connection, lifecycleId);
|
||||
setErrorMessage(appendTransferRelayHint(
|
||||
error.message || '浏览器之间的直连失败,请重新打开分享链接。',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startReceiverPolling(joinedSession.sessionId, lifecycleId);
|
||||
setPhase('waiting');
|
||||
} catch (error) {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
@@ -257,7 +284,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
}
|
||||
|
||||
function startReceiverPolling(sessionId: string, connection: RTCPeerConnection, lifecycleId: number) {
|
||||
function startReceiverPolling(sessionId: string, lifecycleId: number) {
|
||||
let polling = false;
|
||||
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
@@ -276,33 +303,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
cursorRef.current = response.nextCursor;
|
||||
|
||||
for (const item of response.items) {
|
||||
if (item.type === 'offer') {
|
||||
const offer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (!offer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'signal') {
|
||||
setPhase('connecting');
|
||||
await connection.setRemoteDescription(offer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
);
|
||||
const answer = await connection.createAnswer();
|
||||
await connection.setLocalDescription(answer);
|
||||
await postTransferSignal(sessionId, 'receiver', 'answer', JSON.stringify(answer));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'ice-candidate') {
|
||||
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (candidate) {
|
||||
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
candidate,
|
||||
);
|
||||
}
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -344,7 +347,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setArchiveUrl(nextArchiveUrl);
|
||||
}
|
||||
|
||||
async function handleIncomingMessage(data: string | ArrayBuffer | Blob) {
|
||||
async function handleIncomingMessage(data: string | Uint8Array | ArrayBuffer | Blob) {
|
||||
if (typeof data === 'string') {
|
||||
const message = parseTransferControlMessage(data);
|
||||
|
||||
@@ -394,7 +397,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
if (message.type === 'transfer-complete') {
|
||||
await finalizeArchiveDownload();
|
||||
setOverallProgress(100);
|
||||
publishOverallProgress(100, {force: true});
|
||||
setPhase('completed');
|
||||
}
|
||||
|
||||
@@ -418,19 +421,22 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
setPhase('receiving');
|
||||
if (totalBytesRef.current > 0) {
|
||||
setOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100)));
|
||||
publishOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
|
||||
setFiles((current) =>
|
||||
current.map((file) =>
|
||||
file.id === activeFileId
|
||||
? {
|
||||
...file,
|
||||
progress: Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100)),
|
||||
}
|
||||
: file,
|
||||
),
|
||||
);
|
||||
const nextFileProgress = Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100));
|
||||
if (shouldPublishFileProgress(activeFileId, nextFileProgress)) {
|
||||
setFiles((current) =>
|
||||
current.map((file) =>
|
||||
file.id === activeFileId
|
||||
? {
|
||||
...file,
|
||||
progress: nextFileProgress,
|
||||
}
|
||||
: file,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeDownloadableFile(fileId: string) {
|
||||
@@ -531,8 +537,8 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
|
||||
async function submitReceiveRequest(archive: boolean, fileIds?: string[]) {
|
||||
const channel = dataChannelRef.current;
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
const peer = peerRef.current;
|
||||
if (!peer || !peer.connected) {
|
||||
setPhase('error');
|
||||
setErrorMessage('P2P 通道尚未准备好,请稍后再试。');
|
||||
return;
|
||||
@@ -553,7 +559,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
totalBytesRef.current = requestedBytes;
|
||||
receivedBytesRef.current = 0;
|
||||
archiveBuiltRef.current = false;
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setArchiveRequested(archive);
|
||||
setArchiveUrl(null);
|
||||
setRequestSubmitted(true);
|
||||
@@ -568,7 +574,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
})),
|
||||
);
|
||||
|
||||
channel.send(createTransferReceiveRequestMessage(requestedIds, archive));
|
||||
peer.send(createTransferReceiveRequestMessage(requestedIds, archive));
|
||||
setPhase('waiting');
|
||||
}
|
||||
|
||||
@@ -578,9 +584,10 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
try {
|
||||
const result = await lookupTransferSession(receiveCode);
|
||||
setSearchParams({
|
||||
session: result.sessionId,
|
||||
});
|
||||
setSearchParams(buildTransferReceiveSearchParams({
|
||||
sessionId: result.sessionId,
|
||||
receiveCode,
|
||||
}));
|
||||
} catch (error) {
|
||||
setPhase('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '取件码无效或会话已过期');
|
||||
@@ -589,13 +596,30 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
}
|
||||
|
||||
function returnToCodeEntry() {
|
||||
const nextCode = transferSession?.pickupCode ?? receiveCode;
|
||||
cleanupReceiver();
|
||||
setTransferSession(null);
|
||||
setFiles([]);
|
||||
setPhase('idle');
|
||||
setErrorMessage('');
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveUrl(null);
|
||||
setReceiveCode(sanitizeReceiveCode(nextCode));
|
||||
setSearchParams(buildTransferReceiveSearchParams({
|
||||
receiveCode: nextCode,
|
||||
}));
|
||||
}
|
||||
|
||||
const sessionId = searchParams.get('session');
|
||||
const selectedFiles = files.filter((file) => file.selected);
|
||||
const requestedFiles = files.filter((file) => file.requested);
|
||||
const selectedSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
const canZipAllFiles = canArchiveTransferSelection(files);
|
||||
const hasSelectableFiles = selectedFiles.length > 0;
|
||||
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
|
||||
const canSubmitSelection = Boolean(peerRef.current?.connected && hasSelectableFiles);
|
||||
const isOfflineSession = transferSession?.mode === 'OFFLINE';
|
||||
|
||||
const panelContent = (
|
||||
@@ -622,6 +646,17 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
|
||||
onKeyDown={(event) => {
|
||||
if (!canSubmitReceiveCodeLookupOnEnter({
|
||||
key: event.key,
|
||||
receiveCode,
|
||||
lookupBusy,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void handleLookupByCode();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
aria-label="六位取件码"
|
||||
placeholder="请输入 6 位取件码"
|
||||
@@ -649,18 +684,28 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">当前会话</p>
|
||||
<h2 className="text-2xl font-semibold mt-2">{transferSession?.pickupCode ?? '连接中...'}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (sessionId) {
|
||||
void startReceivingSession(sessionId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={returnToCodeEntry}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回输入取件码
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (sessionId) {
|
||||
void startReceivingSession(sessionId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
@@ -774,7 +819,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-cyan-400/20 bg-cyan-500/10 text-cyan-100 hover:bg-cyan-500/15"
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
disabled={!peerRef.current?.connected}
|
||||
onClick={() => void submitReceiveRequest(true, files.map((file) => file.id))}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
hasLoadedDirectoryListing,
|
||||
mergeDirectoryChildren,
|
||||
} from './files-tree';
|
||||
|
||||
@@ -83,6 +84,27 @@ test('buildDirectoryTree marks the active branch and nested folders correctly',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildDirectoryTree does not leak the active branch child into sibling folders', () => {
|
||||
const tree = buildDirectoryTree(
|
||||
{
|
||||
'/': ['文件夹1', '文件夹2'],
|
||||
'/文件夹1': ['子文件夹1'],
|
||||
},
|
||||
['文件夹1', '子文件夹1'],
|
||||
new Set(['/', '/文件夹1', '/文件夹2']),
|
||||
);
|
||||
|
||||
assert.deepEqual(tree[1], {
|
||||
id: '/文件夹2',
|
||||
name: '文件夹2',
|
||||
path: ['文件夹2'],
|
||||
depth: 0,
|
||||
active: false,
|
||||
expanded: true,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('getMissingDirectoryListingPaths requests any unloaded ancestors for a deep current path', () => {
|
||||
assert.deepEqual(
|
||||
getMissingDirectoryListingPaths(
|
||||
@@ -102,3 +124,15 @@ test('getMissingDirectoryListingPaths ignores ancestors that were only inferred
|
||||
[[], ['文档']],
|
||||
);
|
||||
});
|
||||
|
||||
test('hasLoadedDirectoryListing only trusts the loaded listing set instead of inferred tree nodes', () => {
|
||||
assert.equal(
|
||||
hasLoadedDirectoryListing(['文档'], new Set(['/文档'])),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasLoadedDirectoryListing(['文档'], new Set(['/文档/课程资料'])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,13 @@ export function getMissingDirectoryListingPaths(
|
||||
return missingPaths;
|
||||
}
|
||||
|
||||
export function hasLoadedDirectoryListing(
|
||||
pathParts: string[],
|
||||
loadedDirectoryPaths: Set<string>,
|
||||
) {
|
||||
return loadedDirectoryPaths.has(toDirectoryPath(pathParts));
|
||||
}
|
||||
|
||||
export function buildDirectoryTree(
|
||||
directoryChildren: DirectoryChildrenMap,
|
||||
currentPath: string[],
|
||||
@@ -68,7 +75,8 @@ export function buildDirectoryTree(
|
||||
): DirectoryTreeNode[] {
|
||||
function getChildNames(parentPath: string, parentParts: string[]) {
|
||||
const nextNames = new Set(directoryChildren[parentPath] ?? []);
|
||||
const currentChild = currentPath[parentParts.length];
|
||||
const isCurrentBranch = parentParts.every((part, index) => currentPath[index] === part);
|
||||
const currentChild = isCurrentBranch ? currentPath[parentParts.length] : null;
|
||||
if (currentChild) {
|
||||
nextNames.add(currentChild);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import { buildTransferShareUrl } from '../lib/transfer-links';
|
||||
import {
|
||||
buildTransferReceiveSearchParams,
|
||||
canSubmitReceiveCodeLookupOnEnter,
|
||||
getAvailableTransferModes,
|
||||
getOfflineTransferSessionLabel,
|
||||
getOfflineTransferSessionSize,
|
||||
@@ -26,6 +28,44 @@ test('sanitizeReceiveCode keeps only the first six digits', () => {
|
||||
assert.equal(sanitizeReceiveCode(' 98a76-54321 '), '987654');
|
||||
});
|
||||
|
||||
test('buildTransferReceiveSearchParams toggles between session and code entry states', () => {
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ sessionId: 'session-1', receiveCode: ' 98a76-54321 ' }).toString(),
|
||||
'session=session-1&code=987654',
|
||||
);
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ receiveCode: '723325' }).toString(),
|
||||
'code=723325',
|
||||
);
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ receiveCode: '' }).toString(),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('canSubmitReceiveCodeLookupOnEnter only allows Enter when the lookup is ready', () => {
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: false,
|
||||
}), true);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '72332',
|
||||
lookupBusy: false,
|
||||
}), false);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: true,
|
||||
}), false);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Tab',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('formatTransferSize uses readable units', () => {
|
||||
assert.equal(formatTransferSize(0), '0 B');
|
||||
assert.equal(formatTransferSize(2048), '2 KB');
|
||||
|
||||
@@ -11,6 +11,31 @@ export function sanitizeReceiveCode(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
export function buildTransferReceiveSearchParams(params: {
|
||||
sessionId?: string | null;
|
||||
receiveCode?: string | null;
|
||||
}) {
|
||||
const nextParams = new URLSearchParams();
|
||||
if (params.sessionId) {
|
||||
nextParams.set('session', params.sessionId);
|
||||
}
|
||||
|
||||
const normalizedCode = sanitizeReceiveCode(params.receiveCode ?? '');
|
||||
if (normalizedCode) {
|
||||
nextParams.set('code', normalizedCode);
|
||||
}
|
||||
|
||||
return nextParams;
|
||||
}
|
||||
|
||||
export function canSubmitReceiveCodeLookupOnEnter(params: {
|
||||
key: string;
|
||||
receiveCode: string;
|
||||
lookupBusy: boolean;
|
||||
}) {
|
||||
return params.key === 'Enter' && params.receiveCode.length === 6 && !params.lookupBusy;
|
||||
}
|
||||
|
||||
export function formatTransferSize(bytes: number) {
|
||||
if (bytes <= 0) {
|
||||
return '0 B';
|
||||
|
||||
Reference in New Issue
Block a user