Fix Android WebView API access and mobile shell layout

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

101
front/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
front/android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View File

@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "xyz.yoyuzh.portal"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "xyz.yoyuzh.portal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
front/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,5 @@
package xyz.yoyuzh.portal;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">YOYUZH</string>
<string name="title_activity_main">YOYUZH</string>
<string name="package_name">xyz.yoyuzh.portal</string>
<string name="custom_url_scheme">xyz.yoyuzh.portal</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -0,0 +1,31 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
def googleMirror = 'https://maven.aliyun.com/repository/google'
repositories {
maven { url googleMirror }
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
def googleMirror = 'https://maven.aliyun.com/repository/google'
repositories {
maven { url googleMirror }
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View File

@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
front/android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
front/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

View File

@@ -0,0 +1,9 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'xyz.yoyuzh.portal',
appName: 'YOYUZH',
webDir: 'dist'
};
export default config;

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>优立云盘</title>
</head>
<body>

954
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,16 @@
"test": "node --import tsx --test src/**/*.test.ts"
},
"dependencies": {
"@capacitor/android": "^8.3.0",
"@capacitor/cli": "^8.3.0",
"@capacitor/core": "^8.3.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@google/genai": "^1.29.0",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@tailwindcss/vite": "^4.1.14",
"@types/simple-peer": "^9.11.9",
"@vitejs/plugin-react": "^5.0.4",
"better-sqlite3": "^12.4.1",
"clsx": "^2.1.1",
@@ -31,6 +35,7 @@
"react-admin": "^5.14.4",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"simple-peer": "^9.11.1",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ class MemoryStorage implements Storage {
const originalFetch = globalThis.fetch;
const originalStorage = globalThis.localStorage;
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
const originalLocation = globalThis.location;
class FakeXMLHttpRequest {
static latest: FakeXMLHttpRequest | null = null;
@@ -136,6 +137,10 @@ afterEach(() => {
configurable: true,
value: originalXMLHttpRequest,
});
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation,
});
});
test('apiRequest attaches bearer token and unwraps response payload', async () => {
@@ -180,6 +185,74 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
assert.equal(request.url, 'http://localhost/api/files/recent');
});
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
let request: Request | URL | string | undefined;
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new URL('http://localhost'),
});
globalThis.fetch = async (input, init) => {
request =
input instanceof Request
? input
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
ok: true,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
await apiRequest<{ok: boolean}>('/files/recent');
assert.ok(request instanceof Request);
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
});
test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => {
let request: Request | URL | string | undefined;
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new URL('https://localhost'),
});
globalThis.fetch = async (input, init) => {
request =
input instanceof Request
? input
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
ok: true,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
await apiRequest<{ok: boolean}>('/files/recent');
assert.ok(request instanceof Request);
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
});
test('apiRequest throws backend message on business error', async () => {
globalThis.fetch = async () =>
new Response(

View File

@@ -27,8 +27,9 @@ interface ApiBinaryUploadRequestInit {
signal?: AbortSignal;
}
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
const AUTH_REFRESH_PATH = '/auth/refresh';
const DEFAULT_API_BASE_URL = '/api';
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
let refreshRequestPromise: Promise<boolean> | null = null;
@@ -90,13 +91,57 @@ function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attemp
return getRetryDelayMs(attempt);
}
function resolveRuntimeLocation() {
if (typeof globalThis.location !== 'undefined') {
return globalThis.location;
}
if (typeof window !== 'undefined') {
return window.location;
}
return null;
}
function isCapacitorLocalhostOrigin(location: Location | URL | null) {
if (!location) {
return false;
}
const protocol = location.protocol || '';
const hostname = location.hostname || '';
const port = location.port || '';
if (protocol === 'capacitor:') {
return true;
}
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
return isCapacitorLocalScheme && isLocalhostHost && port === '';
}
export function getApiBaseUrl() {
const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, '');
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) {
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`;
}
return DEFAULT_API_BASE_URL;
}
function resolveUrl(path: string) {
if (/^https?:\/\//.test(path)) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
return `${getApiBaseUrl()}${normalizedPath}`;
}
function normalizePath(path: string) {

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_TRANSFER_ICE_SERVERS,
hasRelayTransferIceServer,
resolveTransferIceServers,
} from './transfer-ice';
test('resolveTransferIceServers falls back to the default STUN list when no custom config is provided', () => {
assert.deepEqual(resolveTransferIceServers(), DEFAULT_TRANSFER_ICE_SERVERS);
assert.deepEqual(resolveTransferIceServers(''), DEFAULT_TRANSFER_ICE_SERVERS);
assert.deepEqual(resolveTransferIceServers('not-json'), DEFAULT_TRANSFER_ICE_SERVERS);
});
test('resolveTransferIceServers appends custom TURN servers after the default STUN list', () => {
const iceServers = resolveTransferIceServers(JSON.stringify([
{
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
username: 'portal-user',
credential: 'portal-secret',
},
]));
assert.deepEqual(iceServers, [
...DEFAULT_TRANSFER_ICE_SERVERS,
{
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
username: 'portal-user',
credential: 'portal-secret',
},
]);
});
test('hasRelayTransferIceServer detects whether TURN relay servers are configured', () => {
assert.equal(hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS), false);
assert.equal(hasRelayTransferIceServer(resolveTransferIceServers(JSON.stringify([
{
urls: 'turn:turn.yoyuzh.xyz:3478?transport=udp',
username: 'portal-user',
credential: 'portal-secret',
},
]))), true);
});

View File

@@ -0,0 +1,91 @@
const DEFAULT_STUN_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.cloudflare.com:3478' },
{ urls: 'stun:stun.l.google.com:19302' },
];
const RELAY_HINT =
'当前环境只配置了 STUN跨运营商或手机移动网络通常还需要 TURN 中继。';
type RawIceServer = {
urls?: unknown;
username?: unknown;
credential?: unknown;
};
export const DEFAULT_TRANSFER_ICE_SERVERS = DEFAULT_STUN_ICE_SERVERS;
export function resolveTransferIceServers(rawConfig = import.meta.env?.VITE_TRANSFER_ICE_SERVERS_JSON) {
if (typeof rawConfig !== 'string' || !rawConfig.trim()) {
return DEFAULT_TRANSFER_ICE_SERVERS;
}
try {
const parsed = JSON.parse(rawConfig) as unknown;
if (!Array.isArray(parsed)) {
return DEFAULT_TRANSFER_ICE_SERVERS;
}
const customServers = parsed
.map(normalizeIceServer)
.filter((server): server is RTCIceServer => server != null);
if (customServers.length === 0) {
return DEFAULT_TRANSFER_ICE_SERVERS;
}
return [...DEFAULT_TRANSFER_ICE_SERVERS, ...customServers];
} catch {
return DEFAULT_TRANSFER_ICE_SERVERS;
}
}
export function hasRelayTransferIceServer(iceServers: RTCIceServer[]) {
return iceServers.some((server) => toUrls(server.urls).some((url) => /^turns?:/i.test(url)));
}
export function appendTransferRelayHint(message: string, hasRelaySupport: boolean) {
const normalizedMessage = message.trim();
if (!normalizedMessage || hasRelaySupport || normalizedMessage.includes(RELAY_HINT)) {
return normalizedMessage;
}
return `${normalizedMessage} ${RELAY_HINT}`;
}
function normalizeIceServer(rawServer: RawIceServer) {
const urls = normalizeUrls(rawServer?.urls);
if (urls == null) {
return null;
}
const server: RTCIceServer = { urls };
if (typeof rawServer.username === 'string' && rawServer.username.trim()) {
server.username = rawServer.username.trim();
}
if (typeof rawServer.credential === 'string' && rawServer.credential.trim()) {
server.credential = rawServer.credential.trim();
}
return server;
}
function normalizeUrls(rawUrls: unknown): string | string[] | null {
if (typeof rawUrls === 'string' && rawUrls.trim()) {
return rawUrls.trim();
}
if (!Array.isArray(rawUrls)) {
return null;
}
const urls = rawUrls
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => item.trim());
return urls.length > 0 ? urls : null;
}
function toUrls(urls: string | string[] | undefined) {
if (!urls) {
return [];
}
return Array.isArray(urls) ? urls : [urls];
}

View File

@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import {
createTransferPeer,
parseTransferPeerSignal,
serializeTransferPeerSignal,
type TransferPeerPayload,
} from './transfer-peer';
class FakePeer extends EventEmitter {
destroyed = false;
sent: Array<string | Uint8Array | ArrayBuffer> = [];
signaled: unknown[] = [];
writeReturnValue = true;
bufferSize = 0;
send(payload: string | Uint8Array | ArrayBuffer) {
this.sent.push(payload);
}
write(payload: string | Uint8Array | ArrayBuffer) {
this.sent.push(payload);
return this.writeReturnValue;
}
signal(payload: unknown) {
this.signaled.push(payload);
}
destroy() {
this.destroyed = true;
this.emit('close');
}
}
test('serializeTransferPeerSignal and parseTransferPeerSignal preserve signal payloads', () => {
const payload = {
type: 'offer' as const,
sdp: 'v=0',
};
assert.deepEqual(parseTransferPeerSignal(serializeTransferPeerSignal(payload)), payload);
});
test('createTransferPeer forwards local simple-peer signals to the app layer', () => {
const fakePeer = new FakePeer();
const seenSignals: string[] = [];
let receivedOptions: Record<string, unknown> | null = null;
createTransferPeer({
initiator: true,
onSignal: (payload) => {
seenSignals.push(payload);
},
createPeer: (options) => {
receivedOptions = options as Record<string, unknown>;
return fakePeer as never;
},
});
fakePeer.emit('signal', {
type: 'answer' as const,
sdp: 'v=0',
});
assert.deepEqual(seenSignals, [JSON.stringify({ type: 'answer', sdp: 'v=0' })]);
assert.equal(receivedOptions?.objectMode, true);
});
test('createTransferPeer routes remote signals, data, connect, close, and error events through the adapter', () => {
const fakePeer = new FakePeer();
let connected = 0;
let closed = 0;
const dataPayloads: TransferPeerPayload[] = [];
const errors: string[] = [];
const peer = createTransferPeer({
initiator: false,
onConnect: () => {
connected += 1;
},
onData: (payload) => {
dataPayloads.push(payload);
},
onClose: () => {
closed += 1;
},
onError: (error) => {
errors.push(error.message);
},
createPeer: () => fakePeer as never,
});
peer.applyRemoteSignal(JSON.stringify({ candidate: 'candidate:1' }));
peer.send('hello');
fakePeer.emit('connect');
fakePeer.emit('data', 'payload');
fakePeer.emit('error', new Error('boom'));
peer.destroy();
assert.deepEqual(fakePeer.signaled, [{ candidate: 'candidate:1' }]);
assert.deepEqual(fakePeer.sent, ['hello']);
assert.equal(connected, 1);
assert.deepEqual(dataPayloads, ['payload']);
assert.deepEqual(errors, ['boom']);
assert.equal(closed, 1);
assert.equal(fakePeer.destroyed, true);
});
test('createTransferPeer waits for drain when the wrapped peer applies backpressure', async () => {
const fakePeer = new FakePeer();
fakePeer.bufferSize = 2048;
const peer = createTransferPeer({
initiator: true,
createPeer: () => fakePeer as never,
});
let completed = false;
const writePromise = peer.write('chunk').then(() => {
completed = true;
});
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(completed, false);
fakePeer.emit('drain');
await writePromise;
assert.equal(completed, true);
});
test('createTransferPeer falls back to bufferSize polling when drain is not emitted', async () => {
const fakePeer = new FakePeer();
fakePeer.bufferSize = 2048;
const peer = createTransferPeer({
initiator: true,
createPeer: () => fakePeer as never,
});
let completed = false;
const writePromise = peer.write('chunk').then(() => {
completed = true;
});
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(completed, false);
fakePeer.bufferSize = 0;
await writePromise;
assert.equal(completed, true);
assert.deepEqual(fakePeer.sent, ['chunk']);
});

View File

@@ -0,0 +1,138 @@
import Peer from 'simple-peer/simplepeer.min.js';
import type { Instance as SimplePeerInstance, Options as SimplePeerOptions, SignalData } from 'simple-peer';
export type TransferPeerPayload = string | Uint8Array | ArrayBuffer | Blob;
const TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS = 16;
interface TransferPeerLike {
bufferSize?: number;
connected?: boolean;
destroyed?: boolean;
on(event: 'signal', listener: (signal: SignalData) => void): this;
on(event: 'connect', listener: () => void): this;
on(event: 'data', listener: (data: TransferPeerPayload) => void): this;
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
once?(event: 'drain', listener: () => void): this;
removeListener?(event: 'drain', listener: () => void): this;
signal(signal: SignalData): void;
send(payload: TransferPeerPayload): void;
write?(payload: TransferPeerPayload): boolean;
destroy(): void;
}
export interface TransferPeerAdapter {
readonly connected: boolean;
readonly destroyed: boolean;
applyRemoteSignal(payload: string): void;
send(payload: TransferPeerPayload): void;
write(payload: TransferPeerPayload): Promise<void>;
destroy(): void;
}
export interface CreateTransferPeerOptions {
initiator: boolean;
trickle?: boolean;
peerOptions?: Omit<SimplePeerOptions, 'initiator' | 'trickle'>;
onSignal?: (payload: string) => void;
onConnect?: () => void;
onData?: (payload: TransferPeerPayload) => void;
onClose?: () => void;
onError?: (error: Error) => void;
createPeer?: (options: SimplePeerOptions) => TransferPeerLike;
}
export function serializeTransferPeerSignal(signal: SignalData) {
return JSON.stringify(signal);
}
export function parseTransferPeerSignal(payload: string) {
return JSON.parse(payload) as SignalData;
}
function waitForPeerBufferToClear(peer: TransferPeerLike) {
if (!peer.bufferSize || peer.bufferSize <= 0) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const finish = () => {
if (settled) {
return;
}
settled = true;
if (pollTimer) {
clearInterval(pollTimer);
}
if (peer.removeListener) {
peer.removeListener('drain', finish);
}
resolve();
};
peer.once?.('drain', finish);
pollTimer = setInterval(() => {
if (peer.destroyed || !peer.bufferSize || peer.bufferSize <= 0) {
finish();
}
}, TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS);
});
}
export function createTransferPeer(options: CreateTransferPeerOptions): TransferPeerAdapter {
const peerFactory = options.createPeer ?? ((peerOptions: SimplePeerOptions) => new Peer(peerOptions) as SimplePeerInstance);
const peer = peerFactory({
initiator: options.initiator,
objectMode: true,
trickle: options.trickle ?? true,
...options.peerOptions,
});
peer.on('signal', (signal) => {
options.onSignal?.(serializeTransferPeerSignal(signal));
});
peer.on('connect', () => {
options.onConnect?.();
});
peer.on('data', (payload) => {
options.onData?.(payload);
});
peer.on('close', () => {
options.onClose?.();
});
peer.on('error', (error) => {
options.onError?.(error instanceof Error ? error : new Error(String(error)));
});
return {
get connected() {
return Boolean(peer.connected);
},
get destroyed() {
return Boolean(peer.destroyed);
},
applyRemoteSignal(payload: string) {
peer.signal(parseTransferPeerSignal(payload));
},
send(payload: TransferPeerPayload) {
peer.send(payload);
},
async write(payload: TransferPeerPayload) {
if (!peer.write) {
peer.send(payload);
await waitForPeerBufferToClear(peer);
return;
}
peer.write(payload);
await waitForPeerBufferToClear(peer);
},
destroy() {
peer.destroy();
},
};
}

View File

@@ -118,5 +118,6 @@ test('parseTransferControlMessage returns null for invalid payloads', () => {
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([4, 5, 6]))), [4, 5, 6]);
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
});

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
SAFE_TRANSFER_CHUNK_SIZE,
TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
shouldPublishTransferProgress,
resolveTransferChunkSize,
} from './transfer-runtime';
test('resolveTransferChunkSize prefers a conservative default across browsers', () => {
assert.equal(SAFE_TRANSFER_CHUNK_SIZE, 64 * 1024);
assert.equal(resolveTransferChunkSize(undefined), 64 * 1024);
assert.equal(resolveTransferChunkSize(8 * 1024), 8 * 1024);
assert.equal(resolveTransferChunkSize(256 * 1024), 64 * 1024);
});
test('shouldPublishTransferProgress throttles noisy intermediate updates but always allows forward progress after the interval', () => {
const initialTime = 10_000;
assert.equal(shouldPublishTransferProgress({
nextProgress: 1,
previousProgress: 0,
now: initialTime,
lastPublishedAt: initialTime,
}), false);
assert.equal(shouldPublishTransferProgress({
nextProgress: 1,
previousProgress: 0,
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
lastPublishedAt: initialTime,
}), true);
});
test('shouldPublishTransferProgress always allows terminal or changed progress states through immediately', () => {
const initialTime = 10_000;
assert.equal(shouldPublishTransferProgress({
nextProgress: 100,
previousProgress: 99,
now: initialTime,
lastPublishedAt: initialTime,
}), true);
assert.equal(shouldPublishTransferProgress({
nextProgress: 30,
previousProgress: 30,
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS * 10,
lastPublishedAt: initialTime,
}), false);
});

View File

@@ -1,19 +1,30 @@
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
export const SAFE_TRANSFER_CHUNK_SIZE = 64 * 1024;
export const MAX_TRANSFER_CHUNK_SIZE = 64 * 1024;
export const TRANSFER_PROGRESS_UPDATE_INTERVAL_MS = 120;
export async function waitForTransferChannelDrain(
channel: RTCDataChannel,
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
) {
if (channel.bufferedAmount <= maxBufferedAmount) {
return;
export function resolveTransferChunkSize(maxMessageSize?: number | null) {
if (!Number.isFinite(maxMessageSize) || !maxMessageSize || maxMessageSize <= 0) {
return SAFE_TRANSFER_CHUNK_SIZE;
}
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
window.clearInterval(timer);
resolve();
}
}, 40);
});
return Math.max(1024, Math.min(maxMessageSize, MAX_TRANSFER_CHUNK_SIZE));
}
export function shouldPublishTransferProgress(params: {
nextProgress: number;
previousProgress: number;
now: number;
lastPublishedAt: number;
}) {
const { nextProgress, previousProgress, now, lastPublishedAt } = params;
if (nextProgress === previousProgress) {
return false;
}
if (nextProgress >= 100 || nextProgress <= 0) {
return true;
}
return now - lastPublishedAt >= TRANSFER_PROGRESS_UPDATE_INTERVAL_MS;
}

View File

@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushPendingRemoteIceCandidates,
handleRemoteIceCandidate,
} from './transfer-signaling';
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: null,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const candidate: RTCIceCandidateInit = {
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
};
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
assert.deepEqual(appliedCandidates, []);
assert.deepEqual(pendingCandidates, [candidate]);
});
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: { type: 'answer' } as RTCSessionDescription,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const pendingCandidates: RTCIceCandidateInit[] = [
{
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
{
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
];
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
assert.deepEqual(appliedCandidates, pendingCandidates);
assert.deepEqual(remainingCandidates, []);
});

View File

@@ -1,32 +0,0 @@
interface RemoteIceCapableConnection {
remoteDescription: RTCSessionDescription | null;
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
}
export async function handleRemoteIceCandidate(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
candidate: RTCIceCandidateInit,
) {
if (!connection.remoteDescription) {
return [...pendingCandidates, candidate];
}
await connection.addIceCandidate(candidate);
return pendingCandidates;
}
export async function flushPendingRemoteIceCandidates(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
) {
if (!connection.remoteDescription || pendingCandidates.length === 0) {
return pendingCandidates;
}
for (const candidate of pendingCandidates) {
await connection.addIceCandidate(candidate);
}
return [];
}

View File

@@ -1,8 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { afterEach, test } from 'node:test';
import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer';
const originalLocation = globalThis.location;
afterEach(() => {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation,
});
});
test('toTransferFilePayload keeps relative folder paths for transfer files', () => {
const report = new File(['hello'], 'report.pdf', {
type: 'application/pdf',
@@ -28,3 +37,27 @@ test('buildOfflineTransferDownloadUrl points to the public offline download endp
'/api/transfer/sessions/session-1/files/file-1/download',
);
});
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor localhost shell', () => {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new URL('http://localhost'),
});
assert.equal(
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
);
});
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor https localhost shell', () => {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new URL('https://localhost'),
});
assert.equal(
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
);
});

View File

@@ -1,6 +1,6 @@
import type { FileMetadata, TransferMode } from './types';
import { apiRequest } from './api';
import { apiUploadRequest } from './api';
import { apiRequest, apiUploadRequest, getApiBaseUrl } from './api';
import { hasRelayTransferIceServer, resolveTransferIceServers } from './transfer-ice';
import { getTransferFileRelativePath } from './transfer-protocol';
import type {
LookupTransferSessionResponse,
@@ -8,10 +8,8 @@ import type {
TransferSessionResponse,
} from './types';
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
{urls: 'stun:stun.cloudflare.com:3478'},
{urls: 'stun:stun.l.google.com:19302'},
];
export const DEFAULT_TRANSFER_ICE_SERVERS = resolveTransferIceServers();
export const TRANSFER_HAS_RELAY_SUPPORT = hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS);
export function toTransferFilePayload(files: File[]) {
return files.map((file) => ({
@@ -64,8 +62,7 @@ export function uploadOfflineTransferFile(
}
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
}
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {

View File

@@ -21,6 +21,13 @@ export interface AdminRequestTimelinePoint {
requestCount: number;
}
export interface AdminDailyActiveUserSummary {
metricDate: string;
label: string;
userCount: number;
usernames: string[];
}
export interface AdminSummary {
totalUsers: number;
totalFiles: number;
@@ -30,6 +37,7 @@ export interface AdminSummary {
transferUsageBytes: number;
offlineTransferStorageBytes: number;
offlineTransferStorageLimitBytes: number;
dailyActiveUsers: AdminDailyActiveUserSummary[];
requestTimeline: AdminRequestTimelinePoint[];
inviteCode: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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