Fix Android WebView API access and mobile shell layout
101
front/android/.gitignore
vendored
Normal 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
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
front/android/app/build.gradle
Normal 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")
|
||||
}
|
||||
19
front/android/app/capacitor.build.gradle
Normal 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
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
41
front/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.yoyuzh.portal;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
front/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
front/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
front/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
front/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
front/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
front/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
front/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
front/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
front/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
front/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
BIN
front/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
front/android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
BIN
front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
front/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
22
front/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
5
front/android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
31
front/android/build.gradle
Normal 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
|
||||
}
|
||||
3
front/android/capacitor.settings.gradle
Normal 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')
|
||||
22
front/android/gradle.properties
Normal 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
|
||||
BIN
front/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
front/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
|
||||
5
front/android/settings.gradle
Normal 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'
|
||||
16
front/android/variables.gradle
Normal 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'
|
||||
}
|
||||
9
front/capacitor.config.ts
Normal 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;
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildRequestLineChartModel,
|
||||
buildRequestLineChartXAxisPoints,
|
||||
formatMetricValue,
|
||||
getInviteCodePanelState,
|
||||
parseStorageLimitInput,
|
||||
@@ -19,6 +20,7 @@ test('getInviteCodePanelState returns a copyable invite code when summary contai
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: ' AbCd1234 ',
|
||||
}),
|
||||
@@ -40,6 +42,7 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: ' ',
|
||||
}),
|
||||
@@ -87,3 +90,38 @@ test('buildRequestLineChartModel converts hourly request data into chart coordin
|
||||
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
|
||||
assert.equal(model.peakPoint?.label, '02:00');
|
||||
});
|
||||
|
||||
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
|
||||
const model = buildRequestLineChartModel([
|
||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||
]);
|
||||
|
||||
assert.equal(model.points[0]?.x, 0);
|
||||
assert.equal(model.points.at(-1)?.x, 100);
|
||||
assert.equal(model.points.length, 8);
|
||||
});
|
||||
|
||||
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
|
||||
const model = buildRequestLineChartModel([
|
||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
|
||||
['00:00', '06:00', '07:00'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface RequestLineChartModel {
|
||||
type MetricValueKind = 'bytes' | 'count';
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
|
||||
|
||||
export function formatMetricValue(value: number, kind: MetricValueKind): string {
|
||||
if (kind === 'count') {
|
||||
@@ -103,6 +104,23 @@ export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
|
||||
if (points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstHour = points[0]?.hour ?? 0;
|
||||
const lastHour = points.at(-1)?.hour ?? firstHour;
|
||||
const visibleHours = new Set<number>([firstHour, lastHour]);
|
||||
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
|
||||
if (hour > firstHour && hour < lastHour) {
|
||||
visibleHours.add(hour);
|
||||
}
|
||||
}
|
||||
|
||||
return points.filter((point) => visibleHours.has(point.hour));
|
||||
}
|
||||
|
||||
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
||||
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
||||
if (!inviteCode) {
|
||||
|
||||
@@ -14,7 +14,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { readStoredSession } from '@/src/lib/session';
|
||||
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
|
||||
import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
|
||||
import {
|
||||
buildRequestLineChartModel,
|
||||
buildRequestLineChartXAxisPoints,
|
||||
formatMetricValue,
|
||||
getInviteCodePanelState,
|
||||
parseStorageLimitInput,
|
||||
} from './dashboard-state';
|
||||
|
||||
interface DashboardState {
|
||||
summary: AdminSummary | null;
|
||||
@@ -30,7 +36,6 @@ interface MetricCardDefinition {
|
||||
helper: string;
|
||||
}
|
||||
|
||||
const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]);
|
||||
const DASHBOARD_CARD_BG = '#111827';
|
||||
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
|
||||
const DASHBOARD_CARD_TEXT = '#f8fafc';
|
||||
@@ -129,7 +134,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
const chart = buildRequestLineChartModel(summary.requestTimeline);
|
||||
const currentHour = new Date().getHours();
|
||||
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
|
||||
const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour));
|
||||
const xAxisPoints = buildRequestLineChartXAxisPoints(chart.points);
|
||||
const hasRequests = chart.maxValue > 0;
|
||||
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
|
||||
|
||||
@@ -161,7 +166,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
按小时统计今天的 `/api/**` 请求,当前小时会持续累加,方便判断白天峰值和异常抖动。
|
||||
按小时统计今天已发生的 `/api/**` 请求;曲线会随当天已过时间自然拉长,不再预留未来小时。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -340,21 +345,26 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((point) => (
|
||||
<circle
|
||||
key={point.label}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
|
||||
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{chart.points.map((point) => (
|
||||
<Box
|
||||
key={point.label}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`,
|
||||
width: point.hour === currentPoint?.hour ? 8 : 6,
|
||||
height: point.hour === currentPoint?.hour ? 8 : 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!hasRequests && (
|
||||
<Stack
|
||||
spacing={0.4}
|
||||
@@ -405,6 +415,141 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DailyActiveUsersCard({ summary }: { summary: AdminSummary }) {
|
||||
const latestDay = summary.dailyActiveUsers.at(-1) ?? null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
|
||||
})}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.5}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
>
|
||||
<Stack spacing={0.75}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
最近 7 天上线记录
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
JWT 鉴权成功后会记录当天首次上线用户,只保留最近 7 天,便于回看每天有多少人上线以及具体是谁。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
spacing={0.35}
|
||||
sx={{
|
||||
minWidth: 156,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(16, 185, 129, 0.12)' : '#ecfdf5',
|
||||
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(52, 211, 153, 0.18)' : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
fontWeight={700}
|
||||
>
|
||||
今日上线人数
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={800}
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(latestDay?.userCount ?? 0, 'count')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{latestDay?.label ?? '--'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
{summary.dailyActiveUsers.slice().reverse().map((day) => (
|
||||
<Box
|
||||
key={day.metricDate}
|
||||
sx={(theme) => ({
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.03)' : '#f8fafc',
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.25}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography fontWeight={700}>{day.label}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
px: 0.9,
|
||||
py: 0.3,
|
||||
borderRadius: 99,
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(59, 130, 246, 0.18)' : '#dbeafe',
|
||||
})}
|
||||
>
|
||||
{formatMetricValue(day.userCount, 'count')} 人
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{day.metricDate}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={(theme) => ({
|
||||
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
|
||||
})}
|
||||
>
|
||||
{day.usernames.length > 0 ? day.usernames.join('、') : '当天无人上线'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalAdminDashboard() {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
summary: null,
|
||||
@@ -586,7 +731,12 @@ export function PortalAdminDashboard() {
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{summary && <RequestTrendChart summary={summary} />}
|
||||
{summary && (
|
||||
<Stack spacing={2}>
|
||||
<RequestTrendChart summary={summary} />
|
||||
<DailyActiveUsersCard summary={summary} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
||||
transferUsageBytes: 0,
|
||||
offlineTransferStorageBytes: 0,
|
||||
offlineTransferStorageLimitBytes: 0,
|
||||
dailyActiveUsers: [],
|
||||
requestTimeline: [],
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
@@ -19,12 +19,29 @@
|
||||
--color-glass-active: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:root {
|
||||
--app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
|
||||
--app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--color-bg-base);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
@@ -59,6 +76,14 @@ body {
|
||||
background: var(--color-glass-active);
|
||||
}
|
||||
|
||||
.safe-area-pt {
|
||||
padding-top: var(--app-safe-area-top);
|
||||
}
|
||||
|
||||
.safe-area-pb {
|
||||
padding-bottom: var(--app-safe-area-bottom);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes blob {
|
||||
0% {
|
||||
|
||||
@@ -35,6 +35,7 @@ class MemoryStorage implements Storage {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStorage = globalThis.localStorage;
|
||||
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const originalLocation = globalThis.location;
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static latest: FakeXMLHttpRequest | null = null;
|
||||
@@ -136,6 +137,10 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalXMLHttpRequest,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
||||
@@ -180,6 +185,74 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
|
||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('http://localhost'),
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await apiRequest<{ok: boolean}>('/files/recent');
|
||||
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://localhost'),
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await apiRequest<{ok: boolean}>('/files/recent');
|
||||
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest throws backend message on business error', async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
|
||||
@@ -27,8 +27,9 @@ interface ApiBinaryUploadRequestInit {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
||||
const DEFAULT_API_BASE_URL = '/api';
|
||||
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
|
||||
|
||||
let refreshRequestPromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -90,13 +91,57 @@ function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attemp
|
||||
return getRetryDelayMs(attempt);
|
||||
}
|
||||
|
||||
function resolveRuntimeLocation() {
|
||||
if (typeof globalThis.location !== 'undefined') {
|
||||
return globalThis.location;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCapacitorLocalhostOrigin(location: Location | URL | null) {
|
||||
if (!location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const protocol = location.protocol || '';
|
||||
const hostname = location.hostname || '';
|
||||
const port = location.port || '';
|
||||
|
||||
if (protocol === 'capacitor:') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
|
||||
|
||||
return isCapacitorLocalScheme && isLocalhostHost && port === '';
|
||||
}
|
||||
|
||||
export function getApiBaseUrl() {
|
||||
const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, '');
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
|
||||
if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) {
|
||||
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`;
|
||||
}
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveUrl(path: string) {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
return `${getApiBaseUrl()}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
|
||||
44
front/src/lib/transfer-ice.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
hasRelayTransferIceServer,
|
||||
resolveTransferIceServers,
|
||||
} from './transfer-ice';
|
||||
|
||||
test('resolveTransferIceServers falls back to the default STUN list when no custom config is provided', () => {
|
||||
assert.deepEqual(resolveTransferIceServers(), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
assert.deepEqual(resolveTransferIceServers(''), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
assert.deepEqual(resolveTransferIceServers('not-json'), DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
});
|
||||
|
||||
test('resolveTransferIceServers appends custom TURN servers after the default STUN list', () => {
|
||||
const iceServers = resolveTransferIceServers(JSON.stringify([
|
||||
{
|
||||
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(iceServers, [
|
||||
...DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
{
|
||||
urls: ['turn:turn.yoyuzh.xyz:3478?transport=udp', 'turns:turn.yoyuzh.xyz:5349'],
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('hasRelayTransferIceServer detects whether TURN relay servers are configured', () => {
|
||||
assert.equal(hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS), false);
|
||||
assert.equal(hasRelayTransferIceServer(resolveTransferIceServers(JSON.stringify([
|
||||
{
|
||||
urls: 'turn:turn.yoyuzh.xyz:3478?transport=udp',
|
||||
username: 'portal-user',
|
||||
credential: 'portal-secret',
|
||||
},
|
||||
]))), true);
|
||||
});
|
||||
91
front/src/lib/transfer-ice.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const DEFAULT_STUN_ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
];
|
||||
|
||||
const RELAY_HINT =
|
||||
'当前环境只配置了 STUN,跨运营商或手机移动网络通常还需要 TURN 中继。';
|
||||
|
||||
type RawIceServer = {
|
||||
urls?: unknown;
|
||||
username?: unknown;
|
||||
credential?: unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS = DEFAULT_STUN_ICE_SERVERS;
|
||||
|
||||
export function resolveTransferIceServers(rawConfig = import.meta.env?.VITE_TRANSFER_ICE_SERVERS_JSON) {
|
||||
if (typeof rawConfig !== 'string' || !rawConfig.trim()) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawConfig) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
const customServers = parsed
|
||||
.map(normalizeIceServer)
|
||||
.filter((server): server is RTCIceServer => server != null);
|
||||
|
||||
if (customServers.length === 0) {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
|
||||
return [...DEFAULT_TRANSFER_ICE_SERVERS, ...customServers];
|
||||
} catch {
|
||||
return DEFAULT_TRANSFER_ICE_SERVERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRelayTransferIceServer(iceServers: RTCIceServer[]) {
|
||||
return iceServers.some((server) => toUrls(server.urls).some((url) => /^turns?:/i.test(url)));
|
||||
}
|
||||
|
||||
export function appendTransferRelayHint(message: string, hasRelaySupport: boolean) {
|
||||
const normalizedMessage = message.trim();
|
||||
if (!normalizedMessage || hasRelaySupport || normalizedMessage.includes(RELAY_HINT)) {
|
||||
return normalizedMessage;
|
||||
}
|
||||
return `${normalizedMessage} ${RELAY_HINT}`;
|
||||
}
|
||||
|
||||
function normalizeIceServer(rawServer: RawIceServer) {
|
||||
const urls = normalizeUrls(rawServer?.urls);
|
||||
if (urls == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server: RTCIceServer = { urls };
|
||||
if (typeof rawServer.username === 'string' && rawServer.username.trim()) {
|
||||
server.username = rawServer.username.trim();
|
||||
}
|
||||
if (typeof rawServer.credential === 'string' && rawServer.credential.trim()) {
|
||||
server.credential = rawServer.credential.trim();
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
function normalizeUrls(rawUrls: unknown): string | string[] | null {
|
||||
if (typeof rawUrls === 'string' && rawUrls.trim()) {
|
||||
return rawUrls.trim();
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawUrls)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urls = rawUrls
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim());
|
||||
|
||||
return urls.length > 0 ? urls : null;
|
||||
}
|
||||
|
||||
function toUrls(urls: string | string[] | undefined) {
|
||||
if (!urls) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
153
front/src/lib/transfer-peer.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createTransferPeer,
|
||||
parseTransferPeerSignal,
|
||||
serializeTransferPeerSignal,
|
||||
type TransferPeerPayload,
|
||||
} from './transfer-peer';
|
||||
|
||||
class FakePeer extends EventEmitter {
|
||||
destroyed = false;
|
||||
sent: Array<string | Uint8Array | ArrayBuffer> = [];
|
||||
signaled: unknown[] = [];
|
||||
writeReturnValue = true;
|
||||
bufferSize = 0;
|
||||
|
||||
send(payload: string | Uint8Array | ArrayBuffer) {
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
write(payload: string | Uint8Array | ArrayBuffer) {
|
||||
this.sent.push(payload);
|
||||
return this.writeReturnValue;
|
||||
}
|
||||
|
||||
signal(payload: unknown) {
|
||||
this.signaled.push(payload);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
test('serializeTransferPeerSignal and parseTransferPeerSignal preserve signal payloads', () => {
|
||||
const payload = {
|
||||
type: 'offer' as const,
|
||||
sdp: 'v=0',
|
||||
};
|
||||
|
||||
assert.deepEqual(parseTransferPeerSignal(serializeTransferPeerSignal(payload)), payload);
|
||||
});
|
||||
|
||||
test('createTransferPeer forwards local simple-peer signals to the app layer', () => {
|
||||
const fakePeer = new FakePeer();
|
||||
const seenSignals: string[] = [];
|
||||
let receivedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
createTransferPeer({
|
||||
initiator: true,
|
||||
onSignal: (payload) => {
|
||||
seenSignals.push(payload);
|
||||
},
|
||||
createPeer: (options) => {
|
||||
receivedOptions = options as Record<string, unknown>;
|
||||
return fakePeer as never;
|
||||
},
|
||||
});
|
||||
|
||||
fakePeer.emit('signal', {
|
||||
type: 'answer' as const,
|
||||
sdp: 'v=0',
|
||||
});
|
||||
|
||||
assert.deepEqual(seenSignals, [JSON.stringify({ type: 'answer', sdp: 'v=0' })]);
|
||||
assert.equal(receivedOptions?.objectMode, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer routes remote signals, data, connect, close, and error events through the adapter', () => {
|
||||
const fakePeer = new FakePeer();
|
||||
let connected = 0;
|
||||
let closed = 0;
|
||||
const dataPayloads: TransferPeerPayload[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const peer = createTransferPeer({
|
||||
initiator: false,
|
||||
onConnect: () => {
|
||||
connected += 1;
|
||||
},
|
||||
onData: (payload) => {
|
||||
dataPayloads.push(payload);
|
||||
},
|
||||
onClose: () => {
|
||||
closed += 1;
|
||||
},
|
||||
onError: (error) => {
|
||||
errors.push(error.message);
|
||||
},
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
peer.applyRemoteSignal(JSON.stringify({ candidate: 'candidate:1' }));
|
||||
peer.send('hello');
|
||||
fakePeer.emit('connect');
|
||||
fakePeer.emit('data', 'payload');
|
||||
fakePeer.emit('error', new Error('boom'));
|
||||
peer.destroy();
|
||||
|
||||
assert.deepEqual(fakePeer.signaled, [{ candidate: 'candidate:1' }]);
|
||||
assert.deepEqual(fakePeer.sent, ['hello']);
|
||||
assert.equal(connected, 1);
|
||||
assert.deepEqual(dataPayloads, ['payload']);
|
||||
assert.deepEqual(errors, ['boom']);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(fakePeer.destroyed, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer waits for drain when the wrapped peer applies backpressure', async () => {
|
||||
const fakePeer = new FakePeer();
|
||||
fakePeer.bufferSize = 2048;
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
const writePromise = peer.write('chunk').then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(completed, false);
|
||||
|
||||
fakePeer.emit('drain');
|
||||
await writePromise;
|
||||
assert.equal(completed, true);
|
||||
});
|
||||
|
||||
test('createTransferPeer falls back to bufferSize polling when drain is not emitted', async () => {
|
||||
const fakePeer = new FakePeer();
|
||||
fakePeer.bufferSize = 2048;
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
createPeer: () => fakePeer as never,
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
const writePromise = peer.write('chunk').then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(completed, false);
|
||||
|
||||
fakePeer.bufferSize = 0;
|
||||
await writePromise;
|
||||
assert.equal(completed, true);
|
||||
assert.deepEqual(fakePeer.sent, ['chunk']);
|
||||
});
|
||||
138
front/src/lib/transfer-peer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import Peer from 'simple-peer/simplepeer.min.js';
|
||||
import type { Instance as SimplePeerInstance, Options as SimplePeerOptions, SignalData } from 'simple-peer';
|
||||
|
||||
export type TransferPeerPayload = string | Uint8Array | ArrayBuffer | Blob;
|
||||
|
||||
const TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS = 16;
|
||||
|
||||
interface TransferPeerLike {
|
||||
bufferSize?: number;
|
||||
connected?: boolean;
|
||||
destroyed?: boolean;
|
||||
on(event: 'signal', listener: (signal: SignalData) => void): this;
|
||||
on(event: 'connect', listener: () => void): this;
|
||||
on(event: 'data', listener: (data: TransferPeerPayload) => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
on(event: 'error', listener: (error: Error) => void): this;
|
||||
once?(event: 'drain', listener: () => void): this;
|
||||
removeListener?(event: 'drain', listener: () => void): this;
|
||||
signal(signal: SignalData): void;
|
||||
send(payload: TransferPeerPayload): void;
|
||||
write?(payload: TransferPeerPayload): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface TransferPeerAdapter {
|
||||
readonly connected: boolean;
|
||||
readonly destroyed: boolean;
|
||||
applyRemoteSignal(payload: string): void;
|
||||
send(payload: TransferPeerPayload): void;
|
||||
write(payload: TransferPeerPayload): Promise<void>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface CreateTransferPeerOptions {
|
||||
initiator: boolean;
|
||||
trickle?: boolean;
|
||||
peerOptions?: Omit<SimplePeerOptions, 'initiator' | 'trickle'>;
|
||||
onSignal?: (payload: string) => void;
|
||||
onConnect?: () => void;
|
||||
onData?: (payload: TransferPeerPayload) => void;
|
||||
onClose?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
createPeer?: (options: SimplePeerOptions) => TransferPeerLike;
|
||||
}
|
||||
|
||||
export function serializeTransferPeerSignal(signal: SignalData) {
|
||||
return JSON.stringify(signal);
|
||||
}
|
||||
|
||||
export function parseTransferPeerSignal(payload: string) {
|
||||
return JSON.parse(payload) as SignalData;
|
||||
}
|
||||
|
||||
function waitForPeerBufferToClear(peer: TransferPeerLike) {
|
||||
if (!peer.bufferSize || peer.bufferSize <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
if (peer.removeListener) {
|
||||
peer.removeListener('drain', finish);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
peer.once?.('drain', finish);
|
||||
pollTimer = setInterval(() => {
|
||||
if (peer.destroyed || !peer.bufferSize || peer.bufferSize <= 0) {
|
||||
finish();
|
||||
}
|
||||
}, TRANSFER_PEER_BUFFER_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export function createTransferPeer(options: CreateTransferPeerOptions): TransferPeerAdapter {
|
||||
const peerFactory = options.createPeer ?? ((peerOptions: SimplePeerOptions) => new Peer(peerOptions) as SimplePeerInstance);
|
||||
const peer = peerFactory({
|
||||
initiator: options.initiator,
|
||||
objectMode: true,
|
||||
trickle: options.trickle ?? true,
|
||||
...options.peerOptions,
|
||||
});
|
||||
|
||||
peer.on('signal', (signal) => {
|
||||
options.onSignal?.(serializeTransferPeerSignal(signal));
|
||||
});
|
||||
peer.on('connect', () => {
|
||||
options.onConnect?.();
|
||||
});
|
||||
peer.on('data', (payload) => {
|
||||
options.onData?.(payload);
|
||||
});
|
||||
peer.on('close', () => {
|
||||
options.onClose?.();
|
||||
});
|
||||
peer.on('error', (error) => {
|
||||
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
return {
|
||||
get connected() {
|
||||
return Boolean(peer.connected);
|
||||
},
|
||||
get destroyed() {
|
||||
return Boolean(peer.destroyed);
|
||||
},
|
||||
applyRemoteSignal(payload: string) {
|
||||
peer.signal(parseTransferPeerSignal(payload));
|
||||
},
|
||||
send(payload: TransferPeerPayload) {
|
||||
peer.send(payload);
|
||||
},
|
||||
async write(payload: TransferPeerPayload) {
|
||||
if (!peer.write) {
|
||||
peer.send(payload);
|
||||
await waitForPeerBufferToClear(peer);
|
||||
return;
|
||||
}
|
||||
|
||||
peer.write(payload);
|
||||
await waitForPeerBufferToClear(peer);
|
||||
},
|
||||
destroy() {
|
||||
peer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -118,5 +118,6 @@ test('parseTransferControlMessage returns null for invalid payloads', () => {
|
||||
|
||||
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([4, 5, 6]))), [4, 5, 6]);
|
||||
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
|
||||
});
|
||||
|
||||
52
front/src/lib/transfer-runtime.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
SAFE_TRANSFER_CHUNK_SIZE,
|
||||
TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from './transfer-runtime';
|
||||
|
||||
test('resolveTransferChunkSize prefers a conservative default across browsers', () => {
|
||||
assert.equal(SAFE_TRANSFER_CHUNK_SIZE, 64 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(undefined), 64 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(8 * 1024), 8 * 1024);
|
||||
assert.equal(resolveTransferChunkSize(256 * 1024), 64 * 1024);
|
||||
});
|
||||
|
||||
test('shouldPublishTransferProgress throttles noisy intermediate updates but always allows forward progress after the interval', () => {
|
||||
const initialTime = 10_000;
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 1,
|
||||
previousProgress: 0,
|
||||
now: initialTime,
|
||||
lastPublishedAt: initialTime,
|
||||
}), false);
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 1,
|
||||
previousProgress: 0,
|
||||
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS,
|
||||
lastPublishedAt: initialTime,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('shouldPublishTransferProgress always allows terminal or changed progress states through immediately', () => {
|
||||
const initialTime = 10_000;
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 100,
|
||||
previousProgress: 99,
|
||||
now: initialTime,
|
||||
lastPublishedAt: initialTime,
|
||||
}), true);
|
||||
|
||||
assert.equal(shouldPublishTransferProgress({
|
||||
nextProgress: 30,
|
||||
previousProgress: 30,
|
||||
now: initialTime + TRANSFER_PROGRESS_UPDATE_INTERVAL_MS * 10,
|
||||
lastPublishedAt: initialTime,
|
||||
}), false);
|
||||
});
|
||||
@@ -1,19 +1,30 @@
|
||||
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
|
||||
export const SAFE_TRANSFER_CHUNK_SIZE = 64 * 1024;
|
||||
export const MAX_TRANSFER_CHUNK_SIZE = 64 * 1024;
|
||||
export const TRANSFER_PROGRESS_UPDATE_INTERVAL_MS = 120;
|
||||
|
||||
export async function waitForTransferChannelDrain(
|
||||
channel: RTCDataChannel,
|
||||
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
|
||||
) {
|
||||
if (channel.bufferedAmount <= maxBufferedAmount) {
|
||||
return;
|
||||
export function resolveTransferChunkSize(maxMessageSize?: number | null) {
|
||||
if (!Number.isFinite(maxMessageSize) || !maxMessageSize || maxMessageSize <= 0) {
|
||||
return SAFE_TRANSFER_CHUNK_SIZE;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
|
||||
window.clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 40);
|
||||
});
|
||||
return Math.max(1024, Math.min(maxMessageSize, MAX_TRANSFER_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
export function shouldPublishTransferProgress(params: {
|
||||
nextProgress: number;
|
||||
previousProgress: number;
|
||||
now: number;
|
||||
lastPublishedAt: number;
|
||||
}) {
|
||||
const { nextProgress, previousProgress, now, lastPublishedAt } = params;
|
||||
|
||||
if (nextProgress === previousProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextProgress >= 100 || nextProgress <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return now - lastPublishedAt >= TRANSFER_PROGRESS_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
flushPendingRemoteIceCandidates,
|
||||
handleRemoteIceCandidate,
|
||||
} from './transfer-signaling';
|
||||
|
||||
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
|
||||
const appliedCandidates: RTCIceCandidateInit[] = [];
|
||||
const connection = {
|
||||
remoteDescription: null,
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
appliedCandidates.push(candidate);
|
||||
},
|
||||
};
|
||||
const candidate: RTCIceCandidateInit = {
|
||||
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
};
|
||||
|
||||
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
|
||||
|
||||
assert.deepEqual(appliedCandidates, []);
|
||||
assert.deepEqual(pendingCandidates, [candidate]);
|
||||
});
|
||||
|
||||
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
|
||||
const appliedCandidates: RTCIceCandidateInit[] = [];
|
||||
const connection = {
|
||||
remoteDescription: { type: 'answer' } as RTCSessionDescription,
|
||||
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
|
||||
appliedCandidates.push(candidate);
|
||||
},
|
||||
};
|
||||
const pendingCandidates: RTCIceCandidateInit[] = [
|
||||
{
|
||||
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
},
|
||||
{
|
||||
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
|
||||
|
||||
assert.deepEqual(appliedCandidates, pendingCandidates);
|
||||
assert.deepEqual(remainingCandidates, []);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
interface RemoteIceCapableConnection {
|
||||
remoteDescription: RTCSessionDescription | null;
|
||||
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
||||
}
|
||||
|
||||
export async function handleRemoteIceCandidate(
|
||||
connection: RemoteIceCapableConnection,
|
||||
pendingCandidates: RTCIceCandidateInit[],
|
||||
candidate: RTCIceCandidateInit,
|
||||
) {
|
||||
if (!connection.remoteDescription) {
|
||||
return [...pendingCandidates, candidate];
|
||||
}
|
||||
|
||||
await connection.addIceCandidate(candidate);
|
||||
return pendingCandidates;
|
||||
}
|
||||
|
||||
export async function flushPendingRemoteIceCandidates(
|
||||
connection: RemoteIceCapableConnection,
|
||||
pendingCandidates: RTCIceCandidateInit[],
|
||||
) {
|
||||
if (!connection.remoteDescription || pendingCandidates.length === 0) {
|
||||
return pendingCandidates;
|
||||
}
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
await connection.addIceCandidate(candidate);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { afterEach, test } from 'node:test';
|
||||
|
||||
import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer';
|
||||
|
||||
const originalLocation = globalThis.location;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test('toTransferFilePayload keeps relative folder paths for transfer files', () => {
|
||||
const report = new File(['hello'], 'report.pdf', {
|
||||
type: 'application/pdf',
|
||||
@@ -28,3 +37,27 @@ test('buildOfflineTransferDownloadUrl points to the public offline download endp
|
||||
'/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor localhost shell', () => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('http://localhost'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
|
||||
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildOfflineTransferDownloadUrl uses the production api origin inside the Capacitor https localhost shell', () => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new URL('https://localhost'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
|
||||
'https://api.yoyuzh.xyz/api/transfer/sessions/session-1/files/file-1/download',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FileMetadata, TransferMode } from './types';
|
||||
import { apiRequest } from './api';
|
||||
import { apiUploadRequest } from './api';
|
||||
import { apiRequest, apiUploadRequest, getApiBaseUrl } from './api';
|
||||
import { hasRelayTransferIceServer, resolveTransferIceServers } from './transfer-ice';
|
||||
import { getTransferFileRelativePath } from './transfer-protocol';
|
||||
import type {
|
||||
LookupTransferSessionResponse,
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
TransferSessionResponse,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
|
||||
{urls: 'stun:stun.cloudflare.com:3478'},
|
||||
{urls: 'stun:stun.l.google.com:19302'},
|
||||
];
|
||||
export const DEFAULT_TRANSFER_ICE_SERVERS = resolveTransferIceServers();
|
||||
export const TRANSFER_HAS_RELAY_SUPPORT = hasRelayTransferIceServer(DEFAULT_TRANSFER_ICE_SERVERS);
|
||||
|
||||
export function toTransferFilePayload(files: File[]) {
|
||||
return files.map((file) => ({
|
||||
@@ -64,8 +62,7 @@ export function uploadOfflineTransferFile(
|
||||
}
|
||||
|
||||
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
|
||||
const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
}
|
||||
|
||||
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface AdminRequestTimelinePoint {
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyActiveUserSummary {
|
||||
metricDate: string;
|
||||
label: string;
|
||||
userCount: number;
|
||||
usernames: string[];
|
||||
}
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
@@ -30,6 +37,7 @@ export interface AdminSummary {
|
||||
transferUsageBytes: number;
|
||||
offlineTransferStorageBytes: number;
|
||||
offlineTransferStorageLimitBytes: number;
|
||||
dailyActiveUsers: AdminDailyActiveUserSummary[];
|
||||
requestTimeline: AdminRequestTimelinePoint[];
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getVisibleNavItems } from './MobileLayout';
|
||||
import {
|
||||
getMobileViewportOffsetClassNames,
|
||||
getVisibleNavItems,
|
||||
isNativeMobileShellLocation,
|
||||
} from './MobileLayout';
|
||||
|
||||
test('mobile navigation hides the games entry', () => {
|
||||
const visiblePaths = getVisibleNavItems(false).map((item) => item.path as string);
|
||||
@@ -9,3 +13,23 @@ test('mobile navigation hides the games entry', () => {
|
||||
assert.equal(visiblePaths.includes('/games'), false);
|
||||
assert.deepEqual(visiblePaths, ['/overview', '/files', '/transfer']);
|
||||
});
|
||||
|
||||
test('mobile layout reserves top safe-area space for the fixed app bar', () => {
|
||||
const offsets = getMobileViewportOffsetClassNames();
|
||||
|
||||
assert.match(offsets.header, /\bsafe-area-pt\b/);
|
||||
assert.match(offsets.main, /var\(--app-safe-area-top\)/);
|
||||
});
|
||||
|
||||
test('mobile layout adds extra top spacing inside the native shell', () => {
|
||||
const offsets = getMobileViewportOffsetClassNames(true);
|
||||
|
||||
assert.match(offsets.header, /\bpt-6\b/);
|
||||
assert.match(offsets.main, /1\.5rem/);
|
||||
});
|
||||
|
||||
test('native mobile shell detection matches Capacitor localhost origins', () => {
|
||||
assert.equal(isNativeMobileShellLocation(new URL('https://localhost')), true);
|
||||
assert.equal(isNativeMobileShellLocation(new URL('http://127.0.0.1')), true);
|
||||
assert.equal(isNativeMobileShellLocation(new URL('https://yoyuzh.xyz')), false);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,33 @@ const NAV_ITEMS = [
|
||||
{ name: '快传', path: '/transfer', icon: Send },
|
||||
] as const;
|
||||
|
||||
export function isNativeMobileShellLocation(location: Location | URL | null) {
|
||||
if (!location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = location.hostname || '';
|
||||
const protocol = location.protocol || '';
|
||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:';
|
||||
|
||||
return isLocalhostHost && isCapacitorScheme;
|
||||
}
|
||||
|
||||
export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
||||
if (isNativeShell) {
|
||||
return {
|
||||
header: 'safe-area-pt pt-6',
|
||||
main: 'pt-[calc(3.5rem+1.5rem+var(--app-safe-area-top))]',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
header: 'safe-area-pt',
|
||||
main: 'pt-[calc(3.5rem+var(--app-safe-area-top))]',
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveModal = 'security' | 'settings' | null;
|
||||
|
||||
export function getVisibleNavItems(isAdmin: boolean) {
|
||||
@@ -47,6 +74,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||
const navItems = getVisibleNavItems(isAdmin);
|
||||
const viewportOffsets = getMobileViewportOffsetClassNames(
|
||||
typeof window !== 'undefined' && isNativeMobileShellLocation(window.location),
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
@@ -234,7 +264,7 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Top App Bar */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 w-full glass-panel border-b border-white/5 bg-[#07101D]/70 backdrop-blur-2xl">
|
||||
<header className={cn("fixed top-0 left-0 right-0 z-40 w-full glass-panel border-b border-white/5 bg-[#07101D]/70 backdrop-blur-2xl", viewportOffsets.header)}>
|
||||
<div className="flex items-center justify-between px-4 h-14">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg">
|
||||
@@ -306,7 +336,7 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 w-full overflow-y-auto overflow-x-hidden pt-14 pb-16 z-10">
|
||||
<main className={cn("flex-1 w-full overflow-y-auto overflow-x-hidden pb-16 z-10", viewportOffsets.main)}>
|
||||
{children ?? <Outlet />}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/src/auth/AuthProvider';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
|
||||
import {
|
||||
createTransferFileManifest,
|
||||
@@ -35,12 +36,15 @@ import {
|
||||
createTransferFileMetaMessage,
|
||||
type TransferFileDescriptor,
|
||||
SIGNAL_POLL_INTERVAL_MS,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import {
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
createTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
pollTransferSignals,
|
||||
@@ -92,7 +96,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
||||
|
||||
export default function MobileTransfer() {
|
||||
const navigate = useNavigate();
|
||||
const { session: authSession } = useAuth();
|
||||
const { ready: authReady, session: authSession } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get('session');
|
||||
const isAuthenticated = Boolean(authSession?.token);
|
||||
@@ -118,14 +122,14 @@ export default function MobileTransfer() {
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const bootstrapIdRef = useRef(0);
|
||||
const totalBytesRef = useRef(0);
|
||||
const sentBytesRef = useRef(0);
|
||||
const lastSendProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedSendProgressRef = useRef(0);
|
||||
const sendingStartedRef = useRef(false);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const manifestRef = useRef<TransferFileDescriptor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -193,14 +197,30 @@ export default function MobileTransfer() {
|
||||
|
||||
function cleanupCurrentTransfer() {
|
||||
if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; }
|
||||
if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; }
|
||||
cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = [];
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
cursorRef.current = 0; lastSendProgressPublishAtRef.current = 0; lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false;
|
||||
}
|
||||
|
||||
function publishSendProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedSendProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastSendProgressPublishAtRef.current,
|
||||
})) return;
|
||||
|
||||
lastSendProgressPublishAtRef.current = now;
|
||||
lastPublishedSendProgressRef.current = normalizedProgress;
|
||||
setSendProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function resetSenderState() {
|
||||
cleanupCurrentTransfer();
|
||||
setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError('');
|
||||
setSession(null); setSelectedFiles([]); setSendPhase('idle'); publishSendProgress(0, {force: true}); setSendError('');
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
@@ -236,7 +256,7 @@ export default function MobileTransfer() {
|
||||
bootstrapIdRef.current = bootstrapId;
|
||||
|
||||
cleanupCurrentTransfer();
|
||||
setSendError(''); setSendPhase('creating'); setSendProgress(0);
|
||||
setSendError(''); setSendPhase('creating'); publishSendProgress(0, {force: true});
|
||||
manifestRef.current = createTransferFileManifest(files);
|
||||
totalBytesRef.current = 0; sentBytesRef.current = 0;
|
||||
|
||||
@@ -261,7 +281,7 @@ export default function MobileTransfer() {
|
||||
|
||||
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
setSendPhase('uploading');
|
||||
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true});
|
||||
for (const [idx, file] of files.entries()) {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
const sessionFile = createdSession.files[idx];
|
||||
@@ -271,55 +291,61 @@ export default function MobileTransfer() {
|
||||
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
|
||||
sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded;
|
||||
if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
|
||||
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
});
|
||||
}
|
||||
setSendProgress(100); setSendPhase('completed');
|
||||
publishSendProgress(100, {force: true}); setSendPhase('completed');
|
||||
void loadOfflineHistory({silent: true});
|
||||
}
|
||||
|
||||
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS });
|
||||
const channel = conn.createDataChannel('portal-transfer', { ordered: true });
|
||||
peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer';
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||||
peer.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
},
|
||||
onData: (payload) => {
|
||||
if (typeof payload !== 'string') return;
|
||||
const msg = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
|
||||
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
|
||||
|
||||
conn.onicecandidate = (e) => {
|
||||
if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON()));
|
||||
};
|
||||
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) return;
|
||||
|
||||
conn.onconnectionstatechange = () => {
|
||||
if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||||
if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); }
|
||||
};
|
||||
|
||||
channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
channel.onmessage = (e) => {
|
||||
if (typeof e.data !== 'string') return;
|
||||
const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data);
|
||||
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) return;
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
|
||||
};
|
||||
channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); };
|
||||
startSenderPolling(createdSession.sessionId, conn, bootstrapId);
|
||||
|
||||
const offer = await conn.createOffer();
|
||||
await conn.setLocalDescription(offer);
|
||||
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true});
|
||||
void sendSelectedFiles(peer, files, requestedFiles, bootstrapId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
setSendPhase('error');
|
||||
setSendError(appendTransferRelayHint(
|
||||
error.message || '数据通道建立失败',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startSenderPolling(createdSession.sessionId, bootstrapId);
|
||||
}
|
||||
|
||||
function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) {
|
||||
function startSenderPolling(sessionId: string, bootstrapId: number) {
|
||||
let polling = false;
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
if (polling || bootstrapIdRef.current !== bootstrapId) return;
|
||||
polling = true;
|
||||
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
|
||||
.then(async (res) => {
|
||||
.then((res) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||
cursorRef.current = res.nextCursor;
|
||||
for (const item of res.items) {
|
||||
@@ -327,17 +353,8 @@ export default function MobileTransfer() {
|
||||
setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur));
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'answer' && !conn.currentRemoteDescription) {
|
||||
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (answer) {
|
||||
await conn.setRemoteDescription(answer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'ice-candidate') {
|
||||
const cand = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand);
|
||||
if (item.type === 'signal') {
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -349,28 +366,33 @@ export default function MobileTransfer() {
|
||||
}, SIGNAL_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) {
|
||||
async function sendSelectedFiles(
|
||||
peer: TransferPeerAdapter,
|
||||
files: File[],
|
||||
requestedFiles: TransferFileDescriptor[],
|
||||
bootstrapId: number,
|
||||
) {
|
||||
setSendPhase('transferring');
|
||||
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
|
||||
const chunkSize = resolveTransferChunkSize();
|
||||
|
||||
for (const desc of requestedFiles) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return;
|
||||
const file = filesById.get(desc.id);
|
||||
if (!file) continue;
|
||||
|
||||
channel.send(createTransferFileMetaMessage(desc));
|
||||
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
|
||||
await waitForTransferChannelDrain(channel);
|
||||
channel.send(chunk);
|
||||
peer.send(createTransferFileMetaMessage(desc));
|
||||
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return;
|
||||
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
|
||||
await peer.write(chunk);
|
||||
sentBytesRef.current += chunk.byteLength;
|
||||
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
channel.send(createTransferFileCompleteMessage(desc.id));
|
||||
peer.send(createTransferFileCompleteMessage(desc.id));
|
||||
}
|
||||
channel.send(createTransferCompleteMessage());
|
||||
setSendProgress(100); setSendPhase('completed');
|
||||
peer.send(createTransferCompleteMessage());
|
||||
publishSendProgress(100, {force: true}); setSendPhase('completed');
|
||||
}
|
||||
|
||||
async function copyOfflineSessionLink(s: TransferSessionResponse) {
|
||||
@@ -423,9 +445,9 @@ export default function MobileTransfer() {
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
|
||||
{!isAuthenticated && (
|
||||
{authReady && !isAuthenticated && (
|
||||
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
|
||||
<p className="leading-relaxed">无需登录仅支持在线模式。离线模式可保留文件7天,需登录后可用。</p>
|
||||
<p className="leading-relaxed">无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。</p>
|
||||
<Button variant="outline" size="sm" onClick={navigateBackToLogin} className="w-full bg-white/5 border-white/10 text-white mt-1">
|
||||
<LogIn className="mr-2 h-3.5 w-3.5" /> 去登录
|
||||
</Button>
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
hasLoadedDirectoryListing,
|
||||
mergeDirectoryChildren,
|
||||
toDirectoryPath,
|
||||
type DirectoryChildrenMap,
|
||||
@@ -349,7 +350,7 @@ export default function Files() {
|
||||
}
|
||||
|
||||
next.add(path);
|
||||
shouldLoadChildren = !(path in directoryChildren);
|
||||
shouldLoadChildren = !hasLoadedDirectoryListing(pathParts, loadedDirectoryPaths);
|
||||
return next;
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/src/auth/AuthProvider';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
|
||||
import {
|
||||
createTransferFileManifest,
|
||||
@@ -35,12 +36,15 @@ import {
|
||||
createTransferFileMetaMessage,
|
||||
type TransferFileDescriptor,
|
||||
SIGNAL_POLL_INTERVAL_MS,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import {
|
||||
shouldPublishTransferProgress,
|
||||
resolveTransferChunkSize,
|
||||
} from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
createTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
pollTransferSignals,
|
||||
@@ -108,7 +112,7 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
||||
|
||||
export default function Transfer() {
|
||||
const navigate = useNavigate();
|
||||
const { session: authSession } = useAuth();
|
||||
const { ready: authReady, session: authSession } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get('session');
|
||||
const isAuthenticated = Boolean(authSession?.token);
|
||||
@@ -134,14 +138,14 @@ export default function Transfer() {
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const bootstrapIdRef = useRef(0);
|
||||
const totalBytesRef = useRef(0);
|
||||
const sentBytesRef = useRef(0);
|
||||
const lastSendProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedSendProgressRef = useRef(0);
|
||||
const sendingStartedRef = useRef(false);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const manifestRef = useRef<TransferFileDescriptor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,19 +256,31 @@ export default function Transfer() {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (dataChannelRef.current) {
|
||||
dataChannelRef.current.close();
|
||||
dataChannelRef.current = null;
|
||||
}
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
peerConnectionRef.current.close();
|
||||
peerConnectionRef.current = null;
|
||||
}
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
|
||||
cursorRef.current = 0;
|
||||
lastSendProgressPublishAtRef.current = 0;
|
||||
lastPublishedSendProgressRef.current = 0;
|
||||
sendingStartedRef.current = false;
|
||||
pendingRemoteCandidatesRef.current = [];
|
||||
}
|
||||
|
||||
function publishSendProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedSendProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastSendProgressPublishAtRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSendProgressPublishAtRef.current = now;
|
||||
lastPublishedSendProgressRef.current = normalizedProgress;
|
||||
setSendProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function resetSenderState() {
|
||||
@@ -272,7 +288,7 @@ export default function Transfer() {
|
||||
setSession(null);
|
||||
setSelectedFiles([]);
|
||||
setSendPhase('idle');
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
setSendError('');
|
||||
}
|
||||
|
||||
@@ -334,7 +350,7 @@ export default function Transfer() {
|
||||
cleanupCurrentTransfer();
|
||||
setSendError('');
|
||||
setSendPhase('creating');
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
manifestRef.current = createTransferFileManifest(files);
|
||||
totalBytesRef.current = 0;
|
||||
sentBytesRef.current = 0;
|
||||
@@ -367,7 +383,7 @@ export default function Transfer() {
|
||||
setSendPhase('uploading');
|
||||
totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
setSendProgress(0);
|
||||
publishSendProgress(0, {force: true});
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
@@ -390,95 +406,71 @@ export default function Transfer() {
|
||||
}
|
||||
|
||||
if (totalBytesRef.current > 0) {
|
||||
setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSendProgress(100);
|
||||
publishSendProgress(100, {force: true});
|
||||
setSendPhase('completed');
|
||||
void loadOfflineHistory({silent: true});
|
||||
}
|
||||
|
||||
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
});
|
||||
const channel = connection.createDataChannel('portal-transfer', {
|
||||
ordered: true,
|
||||
});
|
||||
|
||||
peerConnectionRef.current = connection;
|
||||
dataChannelRef.current = channel;
|
||||
channel.binaryType = 'arraybuffer';
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
void postTransferSignal(
|
||||
createdSession.sessionId,
|
||||
'sender',
|
||||
'ice-candidate',
|
||||
JSON.stringify(event.candidate.toJSON()),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (connection.connectionState === 'connected') {
|
||||
const peer = createTransferPeer({
|
||||
initiator: true,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
return;
|
||||
}
|
||||
setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting'));
|
||||
}
|
||||
peer.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
},
|
||||
onData: (payload) => {
|
||||
if (typeof payload !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
|
||||
const message = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
|
||||
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds) || sendingStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
publishSendProgress(0, {force: true});
|
||||
void sendSelectedFiles(peer, files, requestedFiles, bootstrapId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (bootstrapIdRef.current !== bootstrapId) {
|
||||
return;
|
||||
}
|
||||
setSendPhase('error');
|
||||
setSendError('浏览器直连失败,请重新生成分享链接再试一次。');
|
||||
}
|
||||
};
|
||||
|
||||
channel.onopen = () => {
|
||||
channel.send(createTransferFileManifestMessage(manifestRef.current));
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data);
|
||||
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendingStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
|
||||
if (requestedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingStartedRef.current = true;
|
||||
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
sentBytesRef.current = 0;
|
||||
setSendProgress(0);
|
||||
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
|
||||
};
|
||||
|
||||
channel.onerror = () => {
|
||||
setSendPhase('error');
|
||||
setSendError('数据通道建立失败,请重新开始本次快传。');
|
||||
};
|
||||
|
||||
startSenderPolling(createdSession.sessionId, connection, bootstrapId);
|
||||
|
||||
const offer = await connection.createOffer();
|
||||
await connection.setLocalDescription(offer);
|
||||
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
|
||||
setSendError(appendTransferRelayHint(
|
||||
error.message || '数据通道建立失败,请重新开始本次快传。',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startSenderPolling(createdSession.sessionId, bootstrapId);
|
||||
}
|
||||
|
||||
function startSenderPolling(sessionId: string, connection: RTCPeerConnection, bootstrapId: number) {
|
||||
function startSenderPolling(sessionId: string, bootstrapId: number) {
|
||||
let polling = false;
|
||||
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
@@ -502,27 +494,8 @@ export default function Transfer() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'answer' && !connection.currentRemoteDescription) {
|
||||
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (answer) {
|
||||
await connection.setRemoteDescription(answer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'ice-candidate') {
|
||||
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (candidate) {
|
||||
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
candidate,
|
||||
);
|
||||
}
|
||||
if (item.type === 'signal') {
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -540,16 +513,17 @@ export default function Transfer() {
|
||||
}
|
||||
|
||||
async function sendSelectedFiles(
|
||||
channel: RTCDataChannel,
|
||||
peer: TransferPeerAdapter,
|
||||
files: File[],
|
||||
requestedFiles: TransferFileDescriptor[],
|
||||
bootstrapId: number,
|
||||
) {
|
||||
setSendPhase('transferring');
|
||||
const filesById = new Map(files.map((file) => [createTransferFileId(file), file]));
|
||||
const chunkSize = resolveTransferChunkSize();
|
||||
|
||||
for (const descriptor of requestedFiles) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,31 +532,30 @@ export default function Transfer() {
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.send(createTransferFileMetaMessage(descriptor));
|
||||
peer.send(createTransferFileMetaMessage(descriptor));
|
||||
|
||||
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
|
||||
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
|
||||
await waitForTransferChannelDrain(channel);
|
||||
channel.send(chunk);
|
||||
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
|
||||
await peer.write(chunk);
|
||||
sentBytesRef.current += chunk.byteLength;
|
||||
|
||||
if (totalBytesRef.current > 0) {
|
||||
setSendProgress(Math.min(
|
||||
publishSendProgress(Math.min(
|
||||
99,
|
||||
Math.round((sentBytesRef.current / totalBytesRef.current) * 100),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
channel.send(createTransferFileCompleteMessage(descriptor.id));
|
||||
peer.send(createTransferFileCompleteMessage(descriptor.id));
|
||||
}
|
||||
|
||||
channel.send(createTransferCompleteMessage());
|
||||
setSendProgress(100);
|
||||
peer.send(createTransferCompleteMessage());
|
||||
publishSendProgress(100, {force: true});
|
||||
setSendPhase('completed');
|
||||
}
|
||||
|
||||
@@ -650,10 +623,10 @@ export default function Transfer() {
|
||||
) : null}
|
||||
|
||||
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
|
||||
{!isAuthenticated ? (
|
||||
{authReady && !isAuthenticated ? (
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-blue-400/15 bg-blue-500/10 px-4 py-4 text-sm text-blue-100 md:flex-row md:items-center md:justify-between">
|
||||
<p className="leading-6">
|
||||
当前无需登录即可使用快传,但仅支持在线发送和在线接收。离线快传仍需登录后使用。
|
||||
当前无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
DownloadCloud,
|
||||
@@ -17,6 +18,7 @@ import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerMod
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Input } from '@/src/components/ui/input';
|
||||
import { buildTransferArchiveFileName, createTransferZipArchive } from '@/src/lib/transfer-archive';
|
||||
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
|
||||
import { resolveNetdiskSaveDirectory, saveFileToNetdisk } from '@/src/lib/netdisk-upload';
|
||||
import {
|
||||
createTransferReceiveRequestMessage,
|
||||
@@ -25,10 +27,12 @@ import {
|
||||
toTransferChunk,
|
||||
type TransferFileDescriptor,
|
||||
} from '@/src/lib/transfer-protocol';
|
||||
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||
import { shouldPublishTransferProgress } from '@/src/lib/transfer-runtime';
|
||||
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
|
||||
import {
|
||||
buildOfflineTransferDownloadUrl,
|
||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
importOfflineTransferFile,
|
||||
joinTransferSession,
|
||||
lookupTransferSession,
|
||||
@@ -37,7 +41,13 @@ import {
|
||||
} from '@/src/lib/transfer';
|
||||
import type { TransferSessionResponse } from '@/src/lib/types';
|
||||
|
||||
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
|
||||
import {
|
||||
buildTransferReceiveSearchParams,
|
||||
canSubmitReceiveCodeLookupOnEnter,
|
||||
canArchiveTransferSelection,
|
||||
formatTransferSize,
|
||||
sanitizeReceiveCode,
|
||||
} from './transfer-state';
|
||||
|
||||
type ReceivePhase = 'idle' | 'joining' | 'waiting' | 'connecting' | 'receiving' | 'completed' | 'error';
|
||||
|
||||
@@ -54,14 +64,6 @@ interface IncomingTransferFile extends TransferFileDescriptor {
|
||||
receivedBytes: number;
|
||||
}
|
||||
|
||||
function parseJsonPayload<T>(payload: string): T | null {
|
||||
try {
|
||||
return JSON.parse(payload) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TransferReceiveProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
@@ -85,17 +87,19 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
const [savePathPickerFileId, setSavePathPickerFileId] = useState<string | null>(null);
|
||||
const [saveRootPath, setSaveRootPath] = useState('/下载');
|
||||
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const peerRef = useRef<TransferPeerAdapter | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const lifecycleIdRef = useRef(0);
|
||||
const currentFileIdRef = useRef<string | null>(null);
|
||||
const totalBytesRef = useRef(0);
|
||||
const receivedBytesRef = useRef(0);
|
||||
const lastOverallProgressPublishAtRef = useRef(0);
|
||||
const lastPublishedOverallProgressRef = useRef(0);
|
||||
const lastFileProgressPublishAtRef = useRef(new Map<string, number>());
|
||||
const lastPublishedFileProgressRef = useRef(new Map<string, number>());
|
||||
const downloadUrlsRef = useRef<string[]>([]);
|
||||
const requestedFileIdsRef = useRef<string[]>([]);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const archiveBuiltRef = useRef(false);
|
||||
const completedFilesRef = useRef(new Map<string, {
|
||||
name: string;
|
||||
@@ -117,7 +121,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setTransferSession(null);
|
||||
setFiles([]);
|
||||
setPhase('idle');
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveUrl(null);
|
||||
@@ -133,15 +137,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (dataChannelRef.current) {
|
||||
dataChannelRef.current.close();
|
||||
dataChannelRef.current = null;
|
||||
}
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
peerConnectionRef.current.close();
|
||||
peerConnectionRef.current = null;
|
||||
}
|
||||
const peer = peerRef.current;
|
||||
peerRef.current = null;
|
||||
peer?.destroy();
|
||||
|
||||
for (const url of downloadUrlsRef.current) {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -153,11 +151,50 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
cursorRef.current = 0;
|
||||
receivedBytesRef.current = 0;
|
||||
totalBytesRef.current = 0;
|
||||
lastOverallProgressPublishAtRef.current = 0;
|
||||
lastPublishedOverallProgressRef.current = 0;
|
||||
lastFileProgressPublishAtRef.current.clear();
|
||||
lastPublishedFileProgressRef.current.clear();
|
||||
requestedFileIdsRef.current = [];
|
||||
pendingRemoteCandidatesRef.current = [];
|
||||
archiveBuiltRef.current = false;
|
||||
}
|
||||
|
||||
function publishOverallProgress(nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress: lastPublishedOverallProgressRef.current,
|
||||
now,
|
||||
lastPublishedAt: lastOverallProgressPublishAtRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastOverallProgressPublishAtRef.current = now;
|
||||
lastPublishedOverallProgressRef.current = normalizedProgress;
|
||||
setOverallProgress(normalizedProgress);
|
||||
}
|
||||
|
||||
function shouldPublishFileProgress(fileId: string, nextProgress: number, options?: {force?: boolean}) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
|
||||
const now = globalThis.performance?.now?.() ?? Date.now();
|
||||
const previousProgress = lastPublishedFileProgressRef.current.get(fileId) ?? 0;
|
||||
const lastPublishedAt = lastFileProgressPublishAtRef.current.get(fileId) ?? 0;
|
||||
if (!options?.force && !shouldPublishTransferProgress({
|
||||
nextProgress: normalizedProgress,
|
||||
previousProgress,
|
||||
now,
|
||||
lastPublishedAt,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastFileProgressPublishAtRef.current.set(fileId, now);
|
||||
lastPublishedFileProgressRef.current.set(fileId, normalizedProgress);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReceivingSession(sessionId: string) {
|
||||
const lifecycleId = lifecycleIdRef.current + 1;
|
||||
lifecycleIdRef.current = lifecycleId;
|
||||
@@ -166,7 +203,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setPhase('joining');
|
||||
setErrorMessage('');
|
||||
setFiles([]);
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveName(buildTransferArchiveFileName('快传文件'));
|
||||
@@ -199,53 +236,43 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
setFiles(offlineFiles);
|
||||
setRequestSubmitted(true);
|
||||
setOverallProgress(offlineFiles.length > 0 ? 100 : 0);
|
||||
publishOverallProgress(offlineFiles.length > 0 ? 100 : 0, {force: true});
|
||||
setPhase('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
});
|
||||
peerConnectionRef.current = connection;
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
void postTransferSignal(
|
||||
joinedSession.sessionId,
|
||||
'receiver',
|
||||
'ice-candidate',
|
||||
JSON.stringify(event.candidate.toJSON()),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (connection.connectionState === 'connected') {
|
||||
const peer = createTransferPeer({
|
||||
initiator: false,
|
||||
peerOptions: {
|
||||
config: {
|
||||
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
|
||||
},
|
||||
},
|
||||
onSignal: (payload) => {
|
||||
void postTransferSignal(joinedSession.sessionId, 'receiver', 'signal', payload);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
return;
|
||||
}
|
||||
setPhase((current) => (current === 'completed' ? current : 'connecting'));
|
||||
}
|
||||
|
||||
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
|
||||
},
|
||||
onData: (payload) => {
|
||||
void handleIncomingMessage(payload);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
return;
|
||||
}
|
||||
setPhase('error');
|
||||
setErrorMessage('浏览器之间的直连失败,请重新打开分享链接。');
|
||||
}
|
||||
};
|
||||
|
||||
connection.ondatachannel = (event) => {
|
||||
const channel = event.channel;
|
||||
dataChannelRef.current = channel;
|
||||
channel.binaryType = 'arraybuffer';
|
||||
channel.onopen = () => {
|
||||
setPhase((current) => (current === 'completed' ? current : 'connecting'));
|
||||
};
|
||||
channel.onmessage = (messageEvent) => {
|
||||
void handleIncomingMessage(messageEvent.data);
|
||||
};
|
||||
};
|
||||
|
||||
startReceiverPolling(joinedSession.sessionId, connection, lifecycleId);
|
||||
setErrorMessage(appendTransferRelayHint(
|
||||
error.message || '浏览器之间的直连失败,请重新打开分享链接。',
|
||||
TRANSFER_HAS_RELAY_SUPPORT,
|
||||
));
|
||||
},
|
||||
});
|
||||
peerRef.current = peer;
|
||||
startReceiverPolling(joinedSession.sessionId, lifecycleId);
|
||||
setPhase('waiting');
|
||||
} catch (error) {
|
||||
if (lifecycleIdRef.current !== lifecycleId) {
|
||||
@@ -257,7 +284,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
}
|
||||
|
||||
function startReceiverPolling(sessionId: string, connection: RTCPeerConnection, lifecycleId: number) {
|
||||
function startReceiverPolling(sessionId: string, lifecycleId: number) {
|
||||
let polling = false;
|
||||
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
@@ -276,33 +303,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
cursorRef.current = response.nextCursor;
|
||||
|
||||
for (const item of response.items) {
|
||||
if (item.type === 'offer') {
|
||||
const offer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||
if (!offer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'signal') {
|
||||
setPhase('connecting');
|
||||
await connection.setRemoteDescription(offer);
|
||||
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
);
|
||||
const answer = await connection.createAnswer();
|
||||
await connection.setLocalDescription(answer);
|
||||
await postTransferSignal(sessionId, 'receiver', 'answer', JSON.stringify(answer));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'ice-candidate') {
|
||||
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||
if (candidate) {
|
||||
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
|
||||
connection,
|
||||
pendingRemoteCandidatesRef.current,
|
||||
candidate,
|
||||
);
|
||||
}
|
||||
peerRef.current?.applyRemoteSignal(item.payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -344,7 +347,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
setArchiveUrl(nextArchiveUrl);
|
||||
}
|
||||
|
||||
async function handleIncomingMessage(data: string | ArrayBuffer | Blob) {
|
||||
async function handleIncomingMessage(data: string | Uint8Array | ArrayBuffer | Blob) {
|
||||
if (typeof data === 'string') {
|
||||
const message = parseTransferControlMessage(data);
|
||||
|
||||
@@ -394,7 +397,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
if (message.type === 'transfer-complete') {
|
||||
await finalizeArchiveDownload();
|
||||
setOverallProgress(100);
|
||||
publishOverallProgress(100, {force: true});
|
||||
setPhase('completed');
|
||||
}
|
||||
|
||||
@@ -418,19 +421,22 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
setPhase('receiving');
|
||||
if (totalBytesRef.current > 0) {
|
||||
setOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100)));
|
||||
publishOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100)));
|
||||
}
|
||||
|
||||
setFiles((current) =>
|
||||
current.map((file) =>
|
||||
file.id === activeFileId
|
||||
? {
|
||||
...file,
|
||||
progress: Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100)),
|
||||
}
|
||||
: file,
|
||||
),
|
||||
);
|
||||
const nextFileProgress = Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100));
|
||||
if (shouldPublishFileProgress(activeFileId, nextFileProgress)) {
|
||||
setFiles((current) =>
|
||||
current.map((file) =>
|
||||
file.id === activeFileId
|
||||
? {
|
||||
...file,
|
||||
progress: nextFileProgress,
|
||||
}
|
||||
: file,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeDownloadableFile(fileId: string) {
|
||||
@@ -531,8 +537,8 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
|
||||
async function submitReceiveRequest(archive: boolean, fileIds?: string[]) {
|
||||
const channel = dataChannelRef.current;
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
const peer = peerRef.current;
|
||||
if (!peer || !peer.connected) {
|
||||
setPhase('error');
|
||||
setErrorMessage('P2P 通道尚未准备好,请稍后再试。');
|
||||
return;
|
||||
@@ -553,7 +559,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
totalBytesRef.current = requestedBytes;
|
||||
receivedBytesRef.current = 0;
|
||||
archiveBuiltRef.current = false;
|
||||
setOverallProgress(0);
|
||||
publishOverallProgress(0, {force: true});
|
||||
setArchiveRequested(archive);
|
||||
setArchiveUrl(null);
|
||||
setRequestSubmitted(true);
|
||||
@@ -568,7 +574,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
})),
|
||||
);
|
||||
|
||||
channel.send(createTransferReceiveRequestMessage(requestedIds, archive));
|
||||
peer.send(createTransferReceiveRequestMessage(requestedIds, archive));
|
||||
setPhase('waiting');
|
||||
}
|
||||
|
||||
@@ -578,9 +584,10 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
|
||||
try {
|
||||
const result = await lookupTransferSession(receiveCode);
|
||||
setSearchParams({
|
||||
session: result.sessionId,
|
||||
});
|
||||
setSearchParams(buildTransferReceiveSearchParams({
|
||||
sessionId: result.sessionId,
|
||||
receiveCode,
|
||||
}));
|
||||
} catch (error) {
|
||||
setPhase('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '取件码无效或会话已过期');
|
||||
@@ -589,13 +596,30 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
}
|
||||
}
|
||||
|
||||
function returnToCodeEntry() {
|
||||
const nextCode = transferSession?.pickupCode ?? receiveCode;
|
||||
cleanupReceiver();
|
||||
setTransferSession(null);
|
||||
setFiles([]);
|
||||
setPhase('idle');
|
||||
setErrorMessage('');
|
||||
publishOverallProgress(0, {force: true});
|
||||
setRequestSubmitted(false);
|
||||
setArchiveRequested(false);
|
||||
setArchiveUrl(null);
|
||||
setReceiveCode(sanitizeReceiveCode(nextCode));
|
||||
setSearchParams(buildTransferReceiveSearchParams({
|
||||
receiveCode: nextCode,
|
||||
}));
|
||||
}
|
||||
|
||||
const sessionId = searchParams.get('session');
|
||||
const selectedFiles = files.filter((file) => file.selected);
|
||||
const requestedFiles = files.filter((file) => file.requested);
|
||||
const selectedSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
const canZipAllFiles = canArchiveTransferSelection(files);
|
||||
const hasSelectableFiles = selectedFiles.length > 0;
|
||||
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
|
||||
const canSubmitSelection = Boolean(peerRef.current?.connected && hasSelectableFiles);
|
||||
const isOfflineSession = transferSession?.mode === 'OFFLINE';
|
||||
|
||||
const panelContent = (
|
||||
@@ -622,6 +646,17 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
|
||||
onKeyDown={(event) => {
|
||||
if (!canSubmitReceiveCodeLookupOnEnter({
|
||||
key: event.key,
|
||||
receiveCode,
|
||||
lookupBusy,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void handleLookupByCode();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
aria-label="六位取件码"
|
||||
placeholder="请输入 6 位取件码"
|
||||
@@ -649,18 +684,28 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">当前会话</p>
|
||||
<h2 className="text-2xl font-semibold mt-2">{transferSession?.pickupCode ?? '连接中...'}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (sessionId) {
|
||||
void startReceivingSession(sessionId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={returnToCodeEntry}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回输入取件码
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (sessionId) {
|
||||
void startReceivingSession(sessionId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
@@ -774,7 +819,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-cyan-400/20 bg-cyan-500/10 text-cyan-100 hover:bg-cyan-500/15"
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
disabled={!peerRef.current?.connected}
|
||||
onClick={() => void submitReceiveRequest(true, files.map((file) => file.id))}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
hasLoadedDirectoryListing,
|
||||
mergeDirectoryChildren,
|
||||
} from './files-tree';
|
||||
|
||||
@@ -83,6 +84,27 @@ test('buildDirectoryTree marks the active branch and nested folders correctly',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildDirectoryTree does not leak the active branch child into sibling folders', () => {
|
||||
const tree = buildDirectoryTree(
|
||||
{
|
||||
'/': ['文件夹1', '文件夹2'],
|
||||
'/文件夹1': ['子文件夹1'],
|
||||
},
|
||||
['文件夹1', '子文件夹1'],
|
||||
new Set(['/', '/文件夹1', '/文件夹2']),
|
||||
);
|
||||
|
||||
assert.deepEqual(tree[1], {
|
||||
id: '/文件夹2',
|
||||
name: '文件夹2',
|
||||
path: ['文件夹2'],
|
||||
depth: 0,
|
||||
active: false,
|
||||
expanded: true,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('getMissingDirectoryListingPaths requests any unloaded ancestors for a deep current path', () => {
|
||||
assert.deepEqual(
|
||||
getMissingDirectoryListingPaths(
|
||||
@@ -102,3 +124,15 @@ test('getMissingDirectoryListingPaths ignores ancestors that were only inferred
|
||||
[[], ['文档']],
|
||||
);
|
||||
});
|
||||
|
||||
test('hasLoadedDirectoryListing only trusts the loaded listing set instead of inferred tree nodes', () => {
|
||||
assert.equal(
|
||||
hasLoadedDirectoryListing(['文档'], new Set(['/文档'])),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasLoadedDirectoryListing(['文档'], new Set(['/文档/课程资料'])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,13 @@ export function getMissingDirectoryListingPaths(
|
||||
return missingPaths;
|
||||
}
|
||||
|
||||
export function hasLoadedDirectoryListing(
|
||||
pathParts: string[],
|
||||
loadedDirectoryPaths: Set<string>,
|
||||
) {
|
||||
return loadedDirectoryPaths.has(toDirectoryPath(pathParts));
|
||||
}
|
||||
|
||||
export function buildDirectoryTree(
|
||||
directoryChildren: DirectoryChildrenMap,
|
||||
currentPath: string[],
|
||||
@@ -68,7 +75,8 @@ export function buildDirectoryTree(
|
||||
): DirectoryTreeNode[] {
|
||||
function getChildNames(parentPath: string, parentParts: string[]) {
|
||||
const nextNames = new Set(directoryChildren[parentPath] ?? []);
|
||||
const currentChild = currentPath[parentParts.length];
|
||||
const isCurrentBranch = parentParts.every((part, index) => currentPath[index] === part);
|
||||
const currentChild = isCurrentBranch ? currentPath[parentParts.length] : null;
|
||||
if (currentChild) {
|
||||
nextNames.add(currentChild);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import { buildTransferShareUrl } from '../lib/transfer-links';
|
||||
import {
|
||||
buildTransferReceiveSearchParams,
|
||||
canSubmitReceiveCodeLookupOnEnter,
|
||||
getAvailableTransferModes,
|
||||
getOfflineTransferSessionLabel,
|
||||
getOfflineTransferSessionSize,
|
||||
@@ -26,6 +28,44 @@ test('sanitizeReceiveCode keeps only the first six digits', () => {
|
||||
assert.equal(sanitizeReceiveCode(' 98a76-54321 '), '987654');
|
||||
});
|
||||
|
||||
test('buildTransferReceiveSearchParams toggles between session and code entry states', () => {
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ sessionId: 'session-1', receiveCode: ' 98a76-54321 ' }).toString(),
|
||||
'session=session-1&code=987654',
|
||||
);
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ receiveCode: '723325' }).toString(),
|
||||
'code=723325',
|
||||
);
|
||||
assert.equal(
|
||||
buildTransferReceiveSearchParams({ receiveCode: '' }).toString(),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('canSubmitReceiveCodeLookupOnEnter only allows Enter when the lookup is ready', () => {
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: false,
|
||||
}), true);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '72332',
|
||||
lookupBusy: false,
|
||||
}), false);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Enter',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: true,
|
||||
}), false);
|
||||
assert.equal(canSubmitReceiveCodeLookupOnEnter({
|
||||
key: 'Tab',
|
||||
receiveCode: '723325',
|
||||
lookupBusy: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('formatTransferSize uses readable units', () => {
|
||||
assert.equal(formatTransferSize(0), '0 B');
|
||||
assert.equal(formatTransferSize(2048), '2 KB');
|
||||
|
||||
@@ -11,6 +11,31 @@ export function sanitizeReceiveCode(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
export function buildTransferReceiveSearchParams(params: {
|
||||
sessionId?: string | null;
|
||||
receiveCode?: string | null;
|
||||
}) {
|
||||
const nextParams = new URLSearchParams();
|
||||
if (params.sessionId) {
|
||||
nextParams.set('session', params.sessionId);
|
||||
}
|
||||
|
||||
const normalizedCode = sanitizeReceiveCode(params.receiveCode ?? '');
|
||||
if (normalizedCode) {
|
||||
nextParams.set('code', normalizedCode);
|
||||
}
|
||||
|
||||
return nextParams;
|
||||
}
|
||||
|
||||
export function canSubmitReceiveCodeLookupOnEnter(params: {
|
||||
key: string;
|
||||
receiveCode: string;
|
||||
lookupBusy: boolean;
|
||||
}) {
|
||||
return params.key === 'Enter' && params.receiveCode.length === 6 && !params.lookupBusy;
|
||||
}
|
||||
|
||||
export function formatTransferSize(bytes: number) {
|
||||
if (bytes <= 0) {
|
||||
return '0 B';
|
||||
|
||||