From cc2f30b6e2e7a5143b26b1e98adb40ec4458db6a Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 25 Mar 2026 12:29:03 -0500 Subject: [PATCH] 's' --- .gitignore | 41 + app.json | 48 + app/(tabs)/_layout.tsx | 72 + app/(tabs)/bind.tsx | 366 + app/(tabs)/desk.tsx | 300 + app/(tabs)/index.tsx | 814 ++ app/(tabs)/network.tsx | 423 + app/(tabs)/system.tsx | 208 + app/(tabs)/wechat.tsx | 392 + app/+html.tsx | 38 + app/+not-found.tsx | 40 + app/_layout.tsx | 58 + assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 93252 bytes assets/images/android-icon-background.png | Bin 0 -> 17549 bytes assets/images/android-icon-foreground.png | Bin 0 -> 78796 bytes assets/images/android-icon-monochrome.png | Bin 0 -> 4140 bytes assets/images/favicon.png | Bin 0 -> 1129 bytes assets/images/icon.png | Bin 0 -> 393493 bytes assets/images/splash-icon.png | Bin 0 -> 17547 bytes components/EditScreenInfo.tsx | 77 + components/ExternalLink.tsx | 24 + components/StyledText.tsx | 5 + components/Themed.tsx | 45 + components/useClientOnlyValue.ts | 4 + components/useClientOnlyValue.web.ts | 12 + components/useColorScheme.ts | 6 + components/useColorScheme.web.ts | 8 + constants/Colors.ts | 18 + constants/theme.ts | 13 + context/BindContext.tsx | 65 + hooks/useRecorder.ts | 1048 ++ lib/api.ts | 31 + lib/easyTierVpn.ts | 36 + lib/pcTypes.ts | 88 + lib/scriptUtils.ts | 17 + lib/storage.ts | 39 + lib/types.ts | 54 + lib/youtubeConfigUtils.ts | 71 + .../expo-easytier-vpn/android/build.gradle | 19 + .../results.bin | 1 + .../expo/modules/easytier/BuildConfig.dex | Bin 0 -> 992 bytes .../ExpoEasyTierVpnModule$Companion.dex | Bin 0 -> 1408 bytes ...tion$lambda$3$$inlined$AsyncFunction$1.dex | Bin 0 -> 4016 bytes ...tion$lambda$3$$inlined$AsyncFunction$2.dex | Bin 0 -> 1728 bytes ...tion$lambda$3$$inlined$AsyncFunction$3.dex | Bin 0 -> 4256 bytes ...$3$$inlined$AsyncFunctionWithoutArgs$1.dex | Bin 0 -> 2956 bytes ...ambda$3$$inlined$FunctionWithoutArgs$1.dex | Bin 0 -> 2268 bytes .../easytier/ExpoEasyTierVpnModule.dex | Bin 0 -> 9532 bytes .../desugar_graph.bin | Bin 0 -> 235 bytes .../results.bin | 1 + .../classes/classes_dex/classes.dex | Bin 0 -> 16604 bytes .../expo/modules/easytier/BuildConfig.java | 10 + .../aapt/AndroidManifest.xml | 9 + .../aapt/output-metadata.json | 18 + .../aar-metadata.properties | 6 + .../annotationProcessors.json | 1 + .../bundleLibCompileToJarDebug/classes.jar | Bin 0 -> 32300 bytes .../debug/generateDebugRFile/R.jar | Bin 0 -> 294 bytes .../debug/generateDebugRFile/R.txt | 0 .../compile-file-map.properties | 1 + .../debug/packageDebugResources/merger.xml | 2 + .../incremental/mergeDebugAssets/merger.xml | 2 + .../mergeDebugJniLibFolders/merger.xml | 2 + .../incremental/mergeDebugShaders/merger.xml | 2 + .../expo-easytier-vpn_debug.kotlin_module | Bin 0 -> 24 bytes .../expo/modules/easytier/BuildConfig.class | Bin 0 -> 615 bytes .../debug/parseDebugLocalResources/R-def.txt | 2 + .../manifest-merger-blame-debug-report.txt | 11 + .../processDebugManifest/AndroidManifest.xml | 9 + .../extractDeepLinksDebug/navigation.json | 1 + .../nestedResourcesValidationReport.txt | 1 + .../expo-easytier-vpn_debug.kotlin_module | Bin 0 -> 24 bytes .../expo/modules/easytier/BuildConfig.class | Bin 0 -> 615 bytes .../ExpoEasyTierVpnModule$Companion.class | Bin 0 -> 982 bytes ...on$lambda$3$$inlined$AsyncFunction$1.class | Bin 0 -> 4578 bytes ...on$lambda$3$$inlined$AsyncFunction$2.class | Bin 0 -> 1415 bytes ...on$lambda$3$$inlined$AsyncFunction$3.class | Bin 0 -> 4933 bytes ...$$inlined$AsyncFunctionWithoutArgs$1.class | Bin 0 -> 3238 bytes ...bda$3$$inlined$FunctionWithoutArgs$1.class | Bin 0 -> 2273 bytes .../easytier/ExpoEasyTierVpnModule.class | Bin 0 -> 11502 bytes .../bundleLibRuntimeToJarDebug/classes.jar | Bin 0 -> 32028 bytes .../generateDebugRFile/package-aware-r.txt | 1 + .../caches-jvm/inputs/source-to-output.tab | Bin 0 -> 4096 bytes .../inputs/source-to-output.tab.keystream | Bin 0 -> 4096 bytes .../inputs/source-to-output.tab.keystream.len | Bin 0 -> 8 bytes .../inputs/source-to-output.tab.len | Bin 0 -> 8 bytes .../inputs/source-to-output.tab.values.at | Bin 0 -> 1766 bytes .../caches-jvm/inputs/source-to-output.tab_i | Bin 0 -> 32768 bytes .../inputs/source-to-output.tab_i.len | Bin 0 -> 8 bytes .../jvm/kotlin/class-attributes.tab | Bin 0 -> 4096 bytes .../jvm/kotlin/class-attributes.tab.keystream | Bin 0 -> 4096 bytes .../kotlin/class-attributes.tab.keystream.len | Bin 0 -> 8 bytes .../jvm/kotlin/class-attributes.tab.len | Bin 0 -> 8 bytes .../jvm/kotlin/class-attributes.tab.values.at | Bin 0 -> 55 bytes .../jvm/kotlin/class-attributes.tab_i | Bin 0 -> 32768 bytes .../jvm/kotlin/class-attributes.tab_i.len | Bin 0 -> 8 bytes .../jvm/kotlin/class-fq-name-to-source.tab | Bin 0 -> 4096 bytes .../class-fq-name-to-source.tab.keystream | Bin 0 -> 4096 bytes .../class-fq-name-to-source.tab.keystream.len | Bin 0 -> 8 bytes .../kotlin/class-fq-name-to-source.tab.len | Bin 0 -> 8 bytes .../class-fq-name-to-source.tab.values.at | Bin 0 -> 451 bytes .../jvm/kotlin/class-fq-name-to-source.tab_i | Bin 0 -> 32768 bytes .../kotlin/class-fq-name-to-source.tab_i.len | Bin 0 -> 8 bytes .../jvm/kotlin/internal-name-to-source.tab | Bin 0 -> 4096 bytes .../internal-name-to-source.tab.keystream | Bin 0 -> 4096 bytes .../internal-name-to-source.tab.keystream.len | Bin 0 -> 8 bytes .../kotlin/internal-name-to-source.tab.len | Bin 0 -> 8 bytes .../internal-name-to-source.tab.values.at | Bin 0 -> 1456 bytes .../jvm/kotlin/internal-name-to-source.tab_i | Bin 0 -> 32768 bytes .../kotlin/internal-name-to-source.tab_i.len | Bin 0 -> 8 bytes .../cacheable/caches-jvm/jvm/kotlin/proto.tab | Bin 0 -> 4096 bytes .../caches-jvm/jvm/kotlin/proto.tab.keystream | Bin 0 -> 4096 bytes .../jvm/kotlin/proto.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/proto.tab.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/proto.tab.values.at | Bin 0 -> 591 bytes .../caches-jvm/jvm/kotlin/proto.tab_i | Bin 0 -> 32768 bytes .../caches-jvm/jvm/kotlin/proto.tab_i.len | Bin 0 -> 8 bytes .../jvm/kotlin/source-to-classes.tab | Bin 0 -> 4096 bytes .../kotlin/source-to-classes.tab.keystream | Bin 0 -> 4096 bytes .../source-to-classes.tab.keystream.len | Bin 0 -> 8 bytes .../jvm/kotlin/source-to-classes.tab.len | Bin 0 -> 8 bytes .../kotlin/source-to-classes.tab.values.at | Bin 0 -> 1218 bytes .../jvm/kotlin/source-to-classes.tab_i | Bin 0 -> 32768 bytes .../jvm/kotlin/source-to-classes.tab_i.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/subtypes.tab | Bin 0 -> 4096 bytes .../jvm/kotlin/subtypes.tab.keystream | Bin 0 -> 4096 bytes .../jvm/kotlin/subtypes.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/subtypes.tab.len | Bin 0 -> 8 bytes .../jvm/kotlin/subtypes.tab.values.at | Bin 0 -> 95 bytes .../caches-jvm/jvm/kotlin/subtypes.tab_i | Bin 0 -> 32768 bytes .../caches-jvm/jvm/kotlin/subtypes.tab_i.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/supertypes.tab | Bin 0 -> 4096 bytes .../jvm/kotlin/supertypes.tab.keystream | Bin 0 -> 4096 bytes .../jvm/kotlin/supertypes.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/jvm/kotlin/supertypes.tab.len | Bin 0 -> 8 bytes .../jvm/kotlin/supertypes.tab.values.at | Bin 0 -> 86 bytes .../caches-jvm/jvm/kotlin/supertypes.tab_i | Bin 0 -> 32768 bytes .../jvm/kotlin/supertypes.tab_i.len | Bin 0 -> 8 bytes .../cacheable/caches-jvm/lookups/counters.tab | 2 + .../caches-jvm/lookups/file-to-id.tab | Bin 0 -> 4096 bytes .../lookups/file-to-id.tab.keystream | Bin 0 -> 4096 bytes .../lookups/file-to-id.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/lookups/file-to-id.tab.len | Bin 0 -> 8 bytes .../lookups/file-to-id.tab.values.at | Bin 0 -> 55 bytes .../caches-jvm/lookups/file-to-id.tab_i | Bin 0 -> 32768 bytes .../caches-jvm/lookups/file-to-id.tab_i.len | Bin 0 -> 8 bytes .../caches-jvm/lookups/id-to-file.tab | Bin 0 -> 4096 bytes .../lookups/id-to-file.tab.keystream | Bin 0 -> 4096 bytes .../lookups/id-to-file.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/lookups/id-to-file.tab.len | Bin 0 -> 8 bytes .../lookups/id-to-file.tab.values.at | Bin 0 -> 149 bytes .../caches-jvm/lookups/id-to-file.tab_i.len | Bin 0 -> 8 bytes .../cacheable/caches-jvm/lookups/lookups.tab | Bin 0 -> 4096 bytes .../caches-jvm/lookups/lookups.tab.keystream | Bin 0 -> 8192 bytes .../lookups/lookups.tab.keystream.len | Bin 0 -> 8 bytes .../caches-jvm/lookups/lookups.tab.len | Bin 0 -> 8 bytes .../caches-jvm/lookups/lookups.tab.values.at | Bin 0 -> 565 bytes .../caches-jvm/lookups/lookups.tab_i | Bin 0 -> 32768 bytes .../caches-jvm/lookups/lookups.tab_i.len | Bin 0 -> 8 bytes .../cacheable/last-build.bin | Bin 0 -> 18 bytes .../shrunk-classpath-snapshot.bin | Bin 0 -> 103391 bytes .../local-state/build-history.bin | Bin 0 -> 31 bytes .../logs/manifest-merger-debug-report.txt | 20 + .../previous-compilation-data.bin | Bin 0 -> 11581 bytes .../expo-easytier-vpn_debug.kotlin_module | Bin 0 -> 24 bytes .../ExpoEasyTierVpnModule$Companion.class | Bin 0 -> 982 bytes ...on$lambda$3$$inlined$AsyncFunction$1.class | Bin 0 -> 4578 bytes ...on$lambda$3$$inlined$AsyncFunction$2.class | Bin 0 -> 1415 bytes ...on$lambda$3$$inlined$AsyncFunction$3.class | Bin 0 -> 4933 bytes ...$$inlined$AsyncFunctionWithoutArgs$1.class | Bin 0 -> 3238 bytes ...bda$3$$inlined$FunctionWithoutArgs$1.class | Bin 0 -> 2273 bytes .../easytier/ExpoEasyTierVpnModule.class | Bin 0 -> 11502 bytes .../android/src/main/AndroidManifest.xml | 3 + .../modules/easytier/ExpoEasyTierVpnModule.kt | 63 + .../expo-easytier-vpn/expo-module.config.json | 6 + modules/expo-easytier-vpn/package.json | 10 + package-lock.json | 8908 +++++++++++++++++ package.json | 46 + pnpm-lock.yaml | 6888 +++++++++++++ tsconfig.json | 17 + 180 files changed, 20523 insertions(+) create mode 100644 .gitignore create mode 100644 app.json create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/bind.tsx create mode 100644 app/(tabs)/desk.tsx create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/network.tsx create mode 100644 app/(tabs)/system.tsx create mode 100644 app/(tabs)/wechat.tsx create mode 100644 app/+html.tsx create mode 100644 app/+not-found.tsx create mode 100644 app/_layout.tsx create mode 100644 assets/fonts/SpaceMono-Regular.ttf create mode 100644 assets/images/android-icon-background.png create mode 100644 assets/images/android-icon-foreground.png create mode 100644 assets/images/android-icon-monochrome.png create mode 100644 assets/images/favicon.png create mode 100644 assets/images/icon.png create mode 100644 assets/images/splash-icon.png create mode 100644 components/EditScreenInfo.tsx create mode 100644 components/ExternalLink.tsx create mode 100644 components/StyledText.tsx create mode 100644 components/Themed.tsx create mode 100644 components/useClientOnlyValue.ts create mode 100644 components/useClientOnlyValue.web.ts create mode 100644 components/useColorScheme.ts create mode 100644 components/useColorScheme.web.ts create mode 100644 constants/Colors.ts create mode 100644 constants/theme.ts create mode 100644 context/BindContext.tsx create mode 100644 hooks/useRecorder.ts create mode 100644 lib/api.ts create mode 100644 lib/easyTierVpn.ts create mode 100644 lib/pcTypes.ts create mode 100644 lib/scriptUtils.ts create mode 100644 lib/storage.ts create mode 100644 lib/types.ts create mode 100644 lib/youtubeConfigUtils.ts create mode 100644 modules/expo-easytier-vpn/android/build.gradle create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/results.bin create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/BuildConfig.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$Companion.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$1.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$2.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$3.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunctionWithoutArgs$1.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$FunctionWithoutArgs$1.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/expo/modules/easytier/ExpoEasyTierVpnModule.dex create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/0e17efa6d728a5bd3dd32c64b4f75de9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/b02bf20a11ebdb297904777660e70eb4/results.bin create mode 100644 modules/expo-easytier-vpn/android/build/.transforms/b02bf20a11ebdb297904777660e70eb4/transformed/classes/classes_dex/classes.dex create mode 100644 modules/expo-easytier-vpn/android/build/generated/source/buildConfig/debug/expo/modules/easytier/BuildConfig.java create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/incremental/mergeDebugAssets/merger.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/incremental/mergeDebugShaders/merger.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/java_res/debug/processDebugJavaRes/out/META-INF/expo-easytier-vpn_debug.kotlin_module create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/expo/modules/easytier/BuildConfig.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/META-INF/expo-easytier-vpn_debug.kotlin_module create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/BuildConfig.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$Companion.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$1.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$2.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$3.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunctionWithoutArgs$1.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$FunctionWithoutArgs$1.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/expo/modules/easytier/ExpoEasyTierVpnModule.class create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar create mode 100644 modules/expo-easytier-vpn/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/counters.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/cacheable/last-build.bin create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin create mode 100644 modules/expo-easytier-vpn/android/build/kotlin/compileDebugKotlin/local-state/build-history.bin create mode 100644 modules/expo-easytier-vpn/android/build/outputs/logs/manifest-merger-debug-report.txt create mode 100644 modules/expo-easytier-vpn/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/META-INF/expo-easytier-vpn_debug.kotlin_module create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$Companion.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$1.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$2.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunction$3.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$AsyncFunctionWithoutArgs$1.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule$definition$lambda$3$$inlined$FunctionWithoutArgs$1.class create mode 100644 modules/expo-easytier-vpn/android/build/tmp/kotlin-classes/debug/expo/modules/easytier/ExpoEasyTierVpnModule.class create mode 100644 modules/expo-easytier-vpn/android/src/main/AndroidManifest.xml create mode 100644 modules/expo-easytier-vpn/android/src/main/java/expo/modules/easytier/ExpoEasyTierVpnModule.kt create mode 100644 modules/expo-easytier-vpn/expo-module.config.json create mode 100644 modules/expo-easytier-vpn/package.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/app.json b/app.json new file mode 100644 index 0000000..03f2e21 --- /dev/null +++ b/app.json @@ -0,0 +1,48 @@ +{ + "expo": { + "name": "直播录制助手", + "slug": "live-recorder-mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "mobile", + "userInterfaceStyle": "automatic", + "splash": { + "image": "./assets/images/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "package": "com.douyin.recorder.remote", + "usesCleartextTraffic": true, + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false, + "permissions": ["CAMERA"] + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-camera", + { + "cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。" + } + ] + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..6f5ec50 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Tabs } from 'expo-router' +import { Ionicons } from '@expo/vector-icons' + +import Colors from '@/constants/Colors' +import { useColorScheme } from '@/components/useColorScheme' +import { useClientOnlyValue } from '@/components/useClientOnlyValue' + +export default function TabLayout() { + const colorScheme = useColorScheme() + + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ) +} diff --git a/app/(tabs)/bind.tsx b/app/(tabs)/bind.tsx new file mode 100644 index 0000000..d10f797 --- /dev/null +++ b/app/(tabs)/bind.tsx @@ -0,0 +1,366 @@ +import { useCallback, useRef, useState } from 'react' +import { + View, + Text, + TextInput, + Pressable, + ScrollView, + StyleSheet, + Linking, + Alert, + Platform, +} from 'react-native' +import { CameraView, useCameraPermissions } from 'expo-camera' +import * as Clipboard from 'expo-clipboard' +import { LinearGradient } from 'expo-linear-gradient' +import { SafeAreaView } from 'react-native-safe-area-context' +import { Ionicons } from '@expo/vector-icons' + +import { useBind } from '@/context/BindContext' +import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types' +import { + checkVpnPermissionGranted, + isExpoEasyTierVpnAvailable, + saveAndPrepareTunnel, +} from '@/lib/easyTierVpn' +import { theme } from '@/constants/theme' + +const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases' +const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0] + +export default function BindScreen() { + const { bind, setBind, clear, ready } = useBind() + const [paste, setPaste] = useState('') + const [msg, setMsg] = useState('') + const [scanOn, setScanOn] = useState(false) + const [perm, requestPerm] = useCameraPermissions() + const scannedRef = useRef(false) + const [etBusy, setEtBusy] = useState(false) + + const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable() + + const applyPayload = useCallback( + async (p: BindPayload | null) => { + if (!p) { + setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。') + return + } + await setBind(p) + setMsg( + etEmbedded + ? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。' + : '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。' + ) + setScanOn(false) + scannedRef.current = false + }, + [etEmbedded, setBind], + ) + + const onBarCode = useCallback( + async ({ data }: { data: string }) => { + if (scannedRef.current) return + scannedRef.current = true + const p = parseBindPayload(data) + await applyPayload(p) + }, + [applyPayload], + ) + + const onPasteApply = useCallback(async () => { + const p = parseBindPayload(paste.trim()) + await applyPayload(p) + }, [paste, applyPayload]) + + const copyEtJson = useCallback(async () => { + if (!bind) return + const chunk = { + networkName: bind.easytier.networkName, + networkSecret: bind.easytier.networkSecret, + peerUrls: bind.easytier.peerUrls, + } + await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2)) + setMsg('已复制 EasyTier 组网 JSON。') + }, [bind]) + + const copyFull = useCallback(async () => { + if (!bind) return + await Clipboard.setStringAsync(JSON.stringify(bind, null, 2)) + setMsg('已复制完整绑定 JSON。') + }, [bind]) + + const testConn = useCallback(async () => { + if (!bind) return + try { + const r = await fetch(`${bind.baseUrl}/health`, { + headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined, + }) + const j = await r.json().catch(() => ({})) + Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`) + } catch (e) { + Alert.alert('连接失败', e instanceof Error ? e.message : String(e)) + } + }, [bind]) + + const retryEmbeddedTunnel = useCallback(async () => { + if (!bind || !etEmbedded) return + setEtBusy(true) + try { + const r = await saveAndPrepareTunnel(bind.easytier) + if (r.needsVpnPermission) { + setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。') + } else if (r.ok && r.vpnPermissionGranted) { + setMsg( + 'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。' + ) + } else { + setMsg(`组网准备结果:${JSON.stringify(r)}`) + } + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setEtBusy(false) + } + }, [bind, etEmbedded]) + + const checkVpn = useCallback(async () => { + if (!etEmbedded) return + setEtBusy(true) + try { + const ok = await checkVpnPermissionGranted() + setMsg( + ok + ? 'VPN 权限已授予。若仍无法访问 API,请确认当前安装包已包含完整原生 EasyTier 数据面。' + : '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。' + ) + } finally { + setEtBusy(false) + } + }, [etEmbedded]) + + if (!ready) { + return ( + + 加载中... + + ) + } + + const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER + + return ( + + + 远程配对 + + 在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier + 参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。 + + + {bind && ( + + 当前已绑定 + {bind.baseUrl} + 组网名 · {bind.easytier.networkName} + 引导节点 · {bootstrapPeer} + {bind.easytier.peerUrls.length > 1 && ( + 额外引导节点 · {bind.easytier.peerUrls.length - 1} 个 + )} + + void testConn()}> + 测试 API + + { + Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [ + { text: '取消', style: 'cancel' }, + { text: '清除', style: 'destructive', onPress: () => void clear() }, + ]) + }} + > + 清除 + + + {etEmbedded ? ( + + void retryEmbeddedTunnel()} + > + 重新应用组网参数 + + void checkVpn()} + > + 检查 VPN 权限 + + + ) : ( + + {Platform.OS === 'ios' + ? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。' + : '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'} + + )} + + )} + + + 说明 + + 推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把 + `tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。 + + + 当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier + 数据面,下一步还需要把 EasyTier Android 原生库继续接进来。 + + void Linking.openURL(ET_RELEASE)} style={styles.linkRow}> + + EasyTier 官方发布与文档 + + {bind && ( + + void copyEtJson()}> + 复制组网 JSON + + void copyFull()}> + 复制完整绑定 JSON + + + )} + + + + 扫码 + {!scanOn ? ( + { + const r = await requestPerm() + if (!r.granted) { + setMsg('需要相机权限才能扫码。') + return + } + scannedRef.current = false + setScanOn(true) + }} + > + + 打开相机扫描 PC 二维码 + + ) : perm?.granted ? ( + + + setScanOn(false)}> + 关闭相机 + + + ) : ( + 没有相机权限。 + )} + + + + 手动粘贴 JSON + + void onPasteApply()}> + 解析并保存 + + + + {!!msg && {msg}} + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + scroll: { padding: 20, paddingBottom: 40 }, + h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 }, + lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 }, + h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 }, + body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 }, + block: { marginBottom: 22 }, + card: { + borderRadius: 12, + padding: 16, + marginBottom: 18, + borderWidth: 1, + borderColor: theme.border, + }, + cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 }, + mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 }, + muted: { color: theme.muted, fontSize: 14 }, + mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 }, + row: { flexDirection: 'row', gap: 10, marginTop: 12 }, + etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 }, + etActions: { gap: 10, marginTop: 12 }, + btn: { + backgroundColor: theme.bgElevated, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.border, + }, + btnDisabled: { opacity: 0.55 }, + btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' }, + btnText: { color: theme.text, fontWeight: '600', fontSize: 14 }, + btnPrimary: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#3d7eff', + paddingVertical: 14, + borderRadius: 10, + }, + btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 }, + btnSecondary: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + backgroundColor: theme.bgCard, + flex: 1, + minWidth: '45%', + }, + btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 }, + input: { + minHeight: 120, + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + padding: 12, + color: theme.text, + fontFamily: 'monospace', + fontSize: 12, + marginBottom: 12, + backgroundColor: theme.bgCard, + }, + camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' }, + camera: { height: 280, width: '100%' }, + camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' }, + linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 }, + link: { color: theme.accent, fontSize: 14 }, + msg: { color: theme.success, fontSize: 14, marginTop: 8 }, +}) diff --git a/app/(tabs)/desk.tsx b/app/(tabs)/desk.tsx new file mode 100644 index 0000000..37d08b5 --- /dev/null +++ b/app/(tabs)/desk.tsx @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { + View, + Text, + ScrollView, + StyleSheet, + RefreshControl, + Pressable, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useBind } from '@/context/BindContext' +import { apiFetch } from '@/lib/api' +import type { ClientOverview, NetworkPayload, RelayLivePayload, SystemMetricsPayload } from '@/lib/pcTypes' +import { getMultiChannelPro } from '@/lib/storage' +import { theme } from '@/constants/theme' + +function fmtTs(ts: unknown): string { + if (typeof ts !== 'number') return '—' + try { + return new Date(ts).toLocaleString() + } catch { + return '—' + } +} + +function fmtDur(sec: number | null | undefined): string { + if (sec == null || Number.isNaN(sec)) return '—' + const s = Math.floor(sec) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const r = s % 60 + if (h > 0) return `${h}h${m}m${r}s` + if (m > 0) return `${m}m${r}s` + return `${r}s` +} + +function SnapshotNet({ data }: { data: unknown }) { + if (!data || typeof data !== 'object') return + const d = data as Record + if ('google' in d && 'douyin' in d) { + const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string } + const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string } + return ( + + + Google {g?.reachable ? '可达' : '不可达'} ·{' '} + {g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'} + + + 抖音 {y?.reachable ? '可达' : '不可达'} ·{' '} + {y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'} + + + ) + } + return +} + +function SnapshotSys({ data }: { data: unknown }) { + if (!data || typeof data !== 'object') return + const d = data as Record + if ('cpu_percent' in d || 'memory' in d) { + const mem = d.memory as { percent?: number } | undefined + return ( + + + CPU {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'} + + 内存 {mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'} + + ) + } + return +} + +export default function DeskScreen() { + const { bind } = useBind() + const [overview, setOverview] = useState(null) + const [proLive, setProLive] = useState(null) + const [proMode, setProMode] = useState(false) + const [netSnap, setNetSnap] = useState(null) + const [sysSnap, setSysSnap] = useState(null) + const [err, setErr] = useState('') + const [refreshing, setRefreshing] = useState(false) + + const load = useCallback(async () => { + if (!bind) { + setOverview(null) + setProLive(null) + setNetSnap(null) + setSysSnap(null) + setErr('') + return + } + setErr('') + try { + const r = await apiFetch(bind, '/client/overview') + if (r.ok) setOverview((await r.json()) as ClientOverview) + else setOverview(null) + + const multi = await getMultiChannelPro() + setProMode(multi) + if (multi) { + const lr = await apiFetch(bind, '/relay_pro/live_status') + if (lr.ok) setProLive((await lr.json()) as RelayLivePayload) + else setProLive(null) + } else { + setProLive(null) + } + + const nr = await apiFetch(bind, '/network_status') + if (nr.ok) setNetSnap((await nr.json()) as NetworkPayload) + else setNetSnap(null) + + const sr = await apiFetch(bind, '/system_metrics') + if (sr.ok) setSysSnap((await sr.json()) as SystemMetricsPayload) + else setSysSnap(null) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, [bind]) + + useFocusEffect( + useCallback(() => { + void load() + }, [load]), + ) + + useEffect(() => { + if (!bind || !proMode) return + const id = setInterval(() => void load(), 12000) + return () => clearInterval(id) + }, [bind, proMode, load]) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + await load() + setRefreshing(false) + }, [load]) + + return ( + + void onRefresh()} tintColor={theme.accent} /> + ) : undefined + } + > + + 仪表盘 + void load()}> + 刷新 + + + + 与桌面端同源:概览、Pro 实况、网络/负载快照。事件与客户端日志仅在桌面端 SQLite。App 仅远程控制 PC,手机不运行 FFmpeg。 + + + {!bind && ( + + 未绑定 PC:配对后显示频道摘要与转播状态。 + + )} + + {!!err && bind ? {err} : null} + + {overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 ? ( + + {overview.relayValidationErrors.map((x, i) => ( + + {x} + + ))} + + ) : null} + + {proMode && ( + + Pro 转播实况 + + {proLive?.youtubeProcess ?? '—'} · 状态 {proLive?.processStatus ?? '—'}{' '} + {proLive?.processOnline ? ( + 运行中 + ) : ( + 未在运行 + )} + + {(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => ( + + + {row.channelName || '未命名频道'} · key …{row.youtubeKeySuffix || '—'} + + 主播 {row.anchor ?? '—'} + + 抖音 {row.douyinHint ?? '—'} + + + 标题 {row.streamTitle ?? '—'} + + 已播时长 {fmtDur(row.durationSec)} + + ))} + {!proLive?.relayRows?.length ? 暂无实况 : null} + + )} + + + + 网络快照 + 更新 {fmtTs(netSnap?.meta?.at)} + + + + 本机负载快照 + 更新 {fmtTs(sysSnap?.ts)} + + + + + + 直播与任务事件 + + 完整事件表、用户操作与客户端运行日志由桌面端本地数据库维护;请在 Windows 客户端查看明细。 + + + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + scroll: { padding: 16, paddingBottom: 40 }, + head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, + h1: { fontSize: 20, fontWeight: '700', color: theme.text }, + lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 }, + banner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(91,157,255,0.1)', + borderWidth: 1, + borderColor: 'rgba(91,157,255,0.25)', + marginBottom: 14, + }, + bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, + btnSec: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bgElevated, + }, + btnSecText: { color: theme.text, fontWeight: '600' }, + err: { color: theme.danger, marginBottom: 10 }, + warnBox: { + padding: 10, + borderRadius: 8, + backgroundColor: 'rgba(245,101,101,0.1)', + marginBottom: 12, + }, + warnText: { color: theme.danger, fontSize: 13, marginBottom: 4 }, + card: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: 'rgba(110,168,254,0.25)', + marginBottom: 12, + }, + cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 10 }, + meta: { fontSize: 13, color: theme.muted, marginBottom: 8 }, + tagOn: { color: theme.success, fontWeight: '700' }, + tagOff: { color: theme.warn, fontWeight: '700' }, + liveRow: { + paddingVertical: 10, + borderTopWidth: 1, + borderTopColor: theme.border, + }, + liveTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 4 }, + liveLine: { fontSize: 12, color: theme.muted, marginTop: 2 }, + rowCards: { flexDirection: 'row', gap: 10, marginBottom: 12 }, + miniCard: { + padding: 12, + borderRadius: 10, + backgroundColor: theme.bgElevated, + borderWidth: 1, + borderColor: theme.border, + minWidth: 0, + }, + miniTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 4 }, + miniMeta: { fontSize: 11, color: theme.muted, marginBottom: 8 }, + snapGrid: { gap: 6 }, + snapLine: { fontSize: 12, color: theme.text, lineHeight: 18 }, + muted: { fontSize: 13, color: theme.muted, lineHeight: 20 }, +}) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..26fef85 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,814 @@ +import { useCallback, useMemo, useState } from 'react' +import { + View, + Text, + ScrollView, + TextInput, + Pressable, + StyleSheet, + ActivityIndicator, + Alert, + RefreshControl, + Linking, +} from 'react-native' +import { Picker } from '@react-native-picker/picker' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useBind } from '@/context/BindContext' +import { useRecorder } from '@/hooks/useRecorder' +import { theme } from '@/constants/theme' +import { + douyinRoomAndHref, + parseYoutubeStreamKeySuffix, + proChannelPm2Process, +} from '@/lib/youtubeConfigUtils' + +function ctrlStyle(action: string): object { + switch (action) { + case 'start': + return styles.ctrl_start + case 'stop': + return styles.ctrl_stop + case 'restart': + return styles.ctrl_restart + case 'status': + return styles.ctrl_status + default: + return {} + } +} + +const variantColor: Record = { + online: theme.success, + stopped: theme.danger, + error: theme.danger, + warn: theme.warn, + offline: theme.danger, + loading: theme.muted, + unknown: theme.accent, + empty: theme.accent, +} + +function ProLiveSwitchGrid({ + rows, + committedChannelKeys, + proChannelEditors, + highlightedChannelId, + loadingAction, + onSelectChannel, + onStart, + onStop, + onRestart, +}: { + rows: { channelId: string; channelName: string; douyinUrl: string; live: boolean }[] + committedChannelKeys: Record + proChannelEditors: Record + highlightedChannelId: string + loadingAction: string | null + onSelectChannel: (channelId: string) => void + onStart: (channelId: string) => void + onStop: (channelId: string) => void + onRestart: (channelId: string) => void +}) { + const pmBusy = loadingAction !== null + return ( + + {rows.map((row) => { + const saved = committedChannelKeys[row.channelId]?.[0] ?? '' + const ed = proChannelEditors[row.channelId] + const activeKey = (saved || ed?.keys?.[0] || '').trim() + const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`) + const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') + const stopDisabled = pmBusy || !row.live + const rowSelected = row.channelId === highlightedChannelId + return ( + + onSelectChannel(row.channelId)}> + + {keySuffix || '—'} + + + {row.live ? '直播中' : '未开播'} + + + {room} · {row.channelName} + + + {href ? ( + void Linking.openURL(href)}> + 在浏览器打开抖音页 + + ) : null} + + onStart(row.channelId)} + > + 开始 + + onStop(row.channelId)} + > + 停止 + + onRestart(row.channelId)} + > + 重新开始 + + + + ) + })} + + ) +} + +function LiveSwitchGrid({ + loadingAction, + onAction, + disabledAll, +}: { + loadingAction: string | null + onAction: (a: 'start' | 'stop' | 'restart' | 'status') => void + disabledAll?: boolean +}) { + return ( + + {( + [ + ['start', '开始直播'], + ['stop', '停止直播'], + ['restart', '重新开始'], + ['status', '刷新状态'], + ] as const + ).map(([action, label]) => ( + { + if (disabledAll) { + Alert.alert('未绑定', '请先在「远程」页完成配对后再操作。') + return + } + if (loadingAction !== null && action !== 'status') return + void onAction(action) + }} + > + {label} + + ))} + + ) +} + +export default function RecordScreen() { + const { bind } = useBind() + const r = useRecorder() + const unbound = !bind + const [refreshing, setRefreshing] = useState(false) + + const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant]) + + const onRefresh = useCallback(async () => { + if (!bind) return + setRefreshing(true) + await r.refreshProcessList() + setRefreshing(false) + }, [bind, r.refreshProcessList]) + + if (r.bootError && bind) { + return ( + + + 无法连接:{r.bootError} + + 常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。 + + void r.refreshProcessList()}> + 重试加载任务列表 + + + + ) + } + + return ( + + void onRefresh()} tintColor={theme.accent} /> + ) : undefined + } + > + {bind && r.transientLoadError ? ( + + + 任务列表刷新失败:{r.transientLoadError} + {'\n'} + 下拉刷新重试;直播状态仍会每 2 秒轮询。 + + + ) : null} + + {unbound && ( + + 未绑定 PC:在「远程」页扫码后,此处会加载任务与实时数据。 + + )} + {bind && ( + + + 以下仅远程控制 PC 上的转播与 PM2 进程;推流与 FFmpeg 在电脑端执行,手机不跑拉流/转码。 + + + )} + + + + r.setCurrentProcess(String(v))} + style={styles.picker} + dropdownIconColor={theme.text} + > + {unbound || !r.entries.length ? ( + + ) : ( + r.entries.map((e) => ( + + )) + )} + + + { + if (unbound) { + Alert.alert('未绑定', '配对后可切换多频道 Pro。') + return + } + void r.togglePro(!r.multiChannelPro) + }} + > + 多频道直播(Pro) + + {r.multiChannelPro ? '开' : '关'} + + + + + + + {r.statusMeta.line} + + + {bind && r.emptyHint ? ( + + 还没有可用的直播任务 + {r.emptyHint} + + ) : unbound ? ( + <> + + 直播开关 + {}} + /> + + + + YouTube 相关设置 + 配对后从 PC 读取 youtube.ini,与桌面端一致。 + + + + + 直播地址列表 + 要转播的直播间地址,一行一个。 + + + + + OBS 推流 + OBS 选自定义推流,服务器: + srt://live.local:10080/live/obs + + + + 当前直播日志 + 每秒刷新 · 配对后显示进程输出与异常 + + {r.logText || '—'} + + + + ) : ( + <> + + 直播开关 + {r.isYoutube && r.multiChannelPro && r.proLiveRows.length > 0 ? ( + r.setProChannelId(cid)} + onStart={(cid) => void r.runProChannelStart(cid)} + onStop={(cid) => void r.runPm2Action('stop', proChannelPm2Process(cid))} + onRestart={(cid) => void r.runProChannelRestart(cid)} + /> + ) : r.isYoutube && r.multiChannelPro && r.proLiveRows.length === 0 ? ( + + {r.proChannelsList.length > 0 + ? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。' + : '暂无频道。'} + + ) : ( + void r.runPm2Action(a)} + /> + )} + + + {r.isYoutube && r.multiChannelPro && ( + + 当前频道 + {r.proChannelsError ? ( + {r.proChannelsError} + ) : ( + <> + + r.setProChannelId(String(v))} + style={styles.picker} + dropdownIconColor={theme.text} + > + {r.proChannelsList.map((c) => ( + + ))} + + + + r.setNewChannelDraft({ keyText: '' })} + > + 新增频道 + + void r.deleteProChannel(r.proChannelId)} + > + + {r.deleteChannelBusyId === r.proChannelId ? '…' : '删除当前频道'} + + + + {r.newChannelDraft ? ( + + 新频道串流密钥 + r.setNewChannelDraft({ keyText: v })} + placeholder="粘贴密钥" + placeholderTextColor={theme.muted} + autoCapitalize="none" + /> + + r.setNewChannelDraft(null)} + > + 取消 + + void r.submitNewChannelDraft()} + > + {r.addChannelBusy ? '…' : '提交'} + + + + ) : null} + + )} + + )} + + {r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && ( + <> + + YouTube 密钥(当前线路) + + void r.saveProChannelKey()} + > + {r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId ? ( + + ) : ( + 保存密钥 + )} + + {r.youtubeStatus} + + + 直播地址列表(当前线路) + + + void r.reloadProUrlFromServer()}> + 重新读取 + + void r.saveProUrlConfig()} + disabled={r.saveUrlLoading} + > + {r.saveUrlLoading ? '…' : '保存地址'} + + + {r.urlStatus} + + + )} + + {r.isYoutube && !r.multiChannelPro && ( + + YouTube 相关设置 + 串流密钥 + + r.setYtIniModel((m) => ({ ...m, keys: [v], activeIndex: 0 })) + } + placeholder="粘贴或编辑串流密钥" + placeholderTextColor={theme.muted} + autoCapitalize="none" + /> + 输出分辨率 + + + r.setYtIniModel((m) => ({ + ...m, + options: { ...m.options, output_resolution: String(v) }, + })) + } + style={styles.picker} + dropdownIconColor={theme.text} + > + + + + + 是否使用 GPU(NVENC) + + + r.setYtIniModel((m) => ({ + ...m, + options: { ...m.options, use_gpu: String(v) }, + })) + } + style={styles.picker} + dropdownIconColor={theme.text} + > + {r.gpuCaps?.nvenc_available ? ( + <> + + + + + ) : ( + + )} + + + 音频降噪 + + + r.setYtIniModel((m) => ({ + ...m, + options: { ...m.options, audio_denoise: String(v) }, + })) + } + style={styles.picker} + dropdownIconColor={theme.text} + > + + + + + + void r.loadYoutubeIniModel()}> + 重新读取 + + void r.saveYoutubeIniModel()} + > + {r.saveYoutubeLoading ? ( + + ) : ( + 保存 + )} + + + {r.youtubeStatus} + {!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && ( + Google 授权与拉取密钥请在 PC 端操作。 + )} + + )} + + {((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && ( + + 直播地址列表 + 要转播的直播间地址写在这里,一行一个。 + + + void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)} + > + 重新读取 + + { + r.setSaveUrlLoading(true) + void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () => + r.setSaveUrlLoading(false), + ) + }} + > + 保存 + + + {r.urlStatus} + + )} + + {!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && ( + + OBS 推流 + 在 OBS 里选「自定义」推流,服务器填下面这一行: + srt://live.local:10080/live/obs + + )} + + + 当前直播日志 + 每 2 秒刷新 · 与上方状态同源 + + {r.logText || '—'} + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + fatalWrap: { flex: 1, padding: 20, justifyContent: 'center' }, + fatalHint: { fontSize: 13, color: theme.muted, lineHeight: 20, marginTop: 12, marginBottom: 20 }, + retryBtn: { + alignSelf: 'flex-start', + backgroundColor: '#3d7eff', + paddingVertical: 12, + paddingHorizontal: 18, + borderRadius: 10, + }, + retryBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 }, + warnBanner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(246,173,85,0.12)', + borderWidth: 1, + borderColor: 'rgba(246,173,85,0.35)', + marginBottom: 14, + }, + warnBannerText: { fontSize: 13, color: theme.warn, lineHeight: 20 }, + scroll: { padding: 16, paddingBottom: 32 }, + banner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(91,157,255,0.1)', + borderWidth: 1, + borderColor: 'rgba(91,157,255,0.25)', + marginBottom: 14, + }, + bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, + toolbar: { marginBottom: 12 }, + proGrid: { gap: 10 }, + proSwitchRow: { + padding: 12, + borderRadius: 10, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bgCard, + gap: 8, + }, + proSwitchRowOn: { borderColor: 'rgba(91,157,255,0.55)', backgroundColor: 'rgba(91,157,255,0.08)' }, + proKeySuffix: { fontFamily: 'monospace', fontSize: 13, color: theme.accent, fontWeight: '600' }, + proTag: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 }, + proTagOn: { backgroundColor: 'rgba(46,160,67,0.2)' }, + proTagOff: { backgroundColor: 'rgba(110,118,129,0.2)' }, + proTagText: { fontSize: 12, fontWeight: '600', color: theme.text }, + proMid: { fontSize: 13, color: theme.text, lineHeight: 20 }, + proLink: { fontSize: 12, color: theme.accent, marginTop: 6, fontWeight: '600' }, + proActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 }, + proBtn: { paddingVertical: 10, paddingHorizontal: 12, borderRadius: 8, minWidth: 72, alignItems: 'center' }, + proBtnStart: { backgroundColor: '#0e7a0d' }, + proBtnStop: { backgroundColor: '#d1342c' }, + proBtnRestart: { backgroundColor: '#e68600' }, + proBtnText: { color: '#fff', fontWeight: '700', fontSize: 12 }, + newChBox: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: theme.border }, + pickerWrap: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + overflow: 'hidden', + backgroundColor: theme.bgCard, + }, + pickerDisabled: { opacity: 0.65 }, + picker: { color: theme.text }, + proRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 12, + paddingVertical: 10, + paddingHorizontal: 12, + backgroundColor: theme.bgCard, + borderRadius: 10, + borderWidth: 1, + borderColor: theme.border, + }, + proLabel: { color: theme.text, fontWeight: '600' }, + proVal: { color: theme.muted, fontWeight: '600' }, + pill: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 8, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 999, + borderWidth: 1, + marginBottom: 14, + }, + dot: { width: 8, height: 8, borderRadius: 4 }, + pillText: { fontSize: 13, fontWeight: '600' }, + emptyCard: { + padding: 20, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: theme.border, + alignItems: 'center', + }, + emptyTitle: { fontSize: 15, fontWeight: '600', color: theme.muted, marginBottom: 10 }, + card: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: theme.border, + marginBottom: 12, + }, + cardAccent: { + borderColor: 'rgba(91,157,255,0.35)', + backgroundColor: 'rgba(91,157,255,0.06)', + }, + cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 10 }, + fieldLbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6, marginTop: 4 }, + inputSingle: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + padding: 10, + color: theme.text, + fontSize: 14, + marginBottom: 10, + backgroundColor: theme.bg, + }, + area: { + minHeight: 100, + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + padding: 10, + color: theme.text, + fontFamily: 'monospace', + fontSize: 12, + marginBottom: 10, + backgroundColor: theme.bg, + }, + inputDisabled: { opacity: 0.85 }, + row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap' }, + btn: { + backgroundColor: '#3d7eff', + paddingVertical: 10, + paddingHorizontal: 18, + borderRadius: 10, + minWidth: 100, + alignItems: 'center', + }, + btnDisabled: { opacity: 0.55 }, + btnText: { color: '#fff', fontWeight: '700' }, + btnSec: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border }, + btnSecText: { color: theme.text, fontWeight: '600' }, + hint: { color: theme.muted, fontSize: 14, lineHeight: 22, textAlign: 'center' }, + hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8, marginBottom: 8 }, + err: { color: theme.danger, fontSize: 14 }, + mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13, marginTop: 6 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' }, + ctrlDisabled: { opacity: 0.5 }, + ctrl_start: { backgroundColor: '#0e7a0d' }, + ctrl_stop: { backgroundColor: '#d1342c' }, + ctrl_restart: { backgroundColor: '#e68600' }, + ctrl_status: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border }, + ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 }, + logBox: { maxHeight: 280, backgroundColor: '#1e2128', borderRadius: 10, padding: 10 }, + log: { fontFamily: 'monospace', fontSize: 11, color: '#d0d4dc', lineHeight: 18 }, +}) diff --git a/app/(tabs)/network.tsx b/app/(tabs)/network.tsx new file mode 100644 index 0000000..d3c069a --- /dev/null +++ b/app/(tabs)/network.tsx @@ -0,0 +1,423 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { + View, + Text, + ScrollView, + StyleSheet, + RefreshControl, + Pressable, + TextInput, + Switch, + Linking, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useBind } from '@/context/BindContext' +import { apiFetch } from '@/lib/api' +import type { NetworkPayload, SiteInfo, SpeedBlock } from '@/lib/pcTypes' +import { theme } from '@/constants/theme' + +const EMPTY_SITE: SiteInfo = { + host: '', + reachable: false, + latency_ms: -1, + error: null, + resolved_ip: null, + country: null, + country_code: null, + geo_error: null, +} + +const EMPTY_SPEED: SpeedBlock = { + label: '', + download_mbps: null, + download_error: null, + download_bytes_sampled: 0, + upload_mbps: null, + upload_error: null, + upload_target: null, +} + +const DEFAULT_NET: NetworkPayload = { + google: EMPTY_SITE, + douyin: EMPTY_SITE, + routes: { different: null, note: '' }, + speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED }, +} + +function fmtMbps(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '—' + return `${v.toFixed(2)} Mbps` +} + +type ProxyState = { enabled: boolean; http: string; socks: string } + +function SiteCard({ + title, + site, + speed, + loading, + variant, + onSolution, +}: { + title: string + site: SiteInfo + speed: SpeedBlock + loading: boolean + variant: 'google' | 'douyin' + onSolution?: () => void +}) { + const ok = site.reachable + let badge: ReactNode + if (loading) { + badge = 检测中 + } else if (ok) { + badge = 可访问 + } else if (variant === 'google' && onSolution) { + badge = ( + + 查看解决方案 + + ) + } else { + badge = 不可访问 + } + + return ( + + + {title} + {badge} + + + + 延迟 + + {loading + ? '—' + : site.latency_ms != null && site.latency_ms >= 0 + ? `${site.latency_ms.toFixed(0)} ms` + : '—'} + + + + 下行(估算) + {loading ? '—' : fmtMbps(speed.download_mbps)} + + + 上行(估算) + {loading ? '—' : fmtMbps(speed.upload_mbps)} + + + 解析 IP + {loading ? '—' : site.resolved_ip ?? '—'} + + + 地区(参考) + + {loading ? '—' : site.country ?? '—'} + {!loading && site.country_code ? ` (${site.country_code})` : ''} + + + + {!loading && (site.error || site.geo_error) ? ( + + {[site.error, site.geo_error].filter(Boolean).join(' · ')} + + ) : null} + + ) +} + +export default function NetworkScreen() { + const { bind } = useBind() + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + const [loading, setLoading] = useState(false) + const [proxy, setProxy] = useState({ enabled: false, http: '', socks: '' }) + const [proxyMsg, setProxyMsg] = useState('') + const [savingProxy, setSavingProxy] = useState(false) + const [refreshing, setRefreshing] = useState(false) + + const loadNet = useCallback(async () => { + if (!bind) { + setErr(null) + setData(null) + return + } + setErr(null) + setLoading(true) + try { + const r = await apiFetch(bind, '/network_status') + const json = (await r.json()) as NetworkPayload & { error?: string } + if (!r.ok) { + setErr(json.error || `请求失败 ${r.status}`) + setData(null) + return + } + setData(json) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + setData(null) + } finally { + setLoading(false) + } + }, [bind]) + + const loadProxy = useCallback(async () => { + if (!bind) return + setProxyMsg('') + try { + const pr = await apiFetch(bind, '/proxy_config') + if (pr.ok) { + const p = (await pr.json()) as ProxyState + setProxy({ + enabled: !!p.enabled, + http: String(p.http || ''), + socks: String(p.socks || ''), + }) + } + } catch { + /* ignore */ + } + }, [bind]) + + const saveProxy = useCallback(async () => { + if (!bind) { + setProxyMsg('请先配对') + return + } + setSavingProxy(true) + setProxyMsg('') + try { + const res = await apiFetch(bind, '/proxy_config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(proxy), + }) + if (!res.ok) throw new Error(String(res.status)) + setProxyMsg('已保存。重启直播任务后生效。') + } catch (e) { + setProxyMsg(e instanceof Error ? e.message : String(e)) + } finally { + setSavingProxy(false) + } + }, [bind, proxy]) + + const loadAll = useCallback(async () => { + await loadNet() + await loadProxy() + }, [loadNet, loadProxy]) + + useEffect(() => { + void loadAll() + }, [loadAll]) + + useEffect(() => { + if (!bind) return + const id = setInterval(() => void loadNet(), 45000) + return () => clearInterval(id) + }, [bind, loadNet]) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + await loadAll() + setRefreshing(false) + }, [loadAll]) + + const view = data ?? DEFAULT_NET + + const openSolution = useCallback(() => { + void Linking.openURL('https://nomadro.com/') + }, []) + + return ( + + void onRefresh()} tintColor={theme.accent} /> + ) : undefined + } + > + 网络 + 连通与测速(与桌面端「网络仪表盘」同源)。 + + {!bind && ( + + 未绑定 PC:配对后可检测 Google / 抖音 与测速。 + + )} + + + + 网络仪表盘 + void loadNet()} disabled={!bind || loading}> + {loading ? '检测中…' : '重新检测'} + + + {err && bind ? {err} : null} + + + + + {view.routes?.note ? ( + {view.routes.note} + ) : null} + + + + 录制专用代理 + + 仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改系统代理。与桌面端「录制专用代理」一致。 + + + 启用仅录制进程使用的代理 + setProxy((p) => ({ ...p, enabled: v }))} + /> + + HTTP 代理 + setProxy((p) => ({ ...p, http: t }))} + placeholder="http://127.0.0.1:7890" + placeholderTextColor={theme.muted} + autoCapitalize="none" + /> + SOCKS5 + setProxy((p) => ({ ...p, socks: t }))} + placeholder="socks5://127.0.0.1:7890" + placeholderTextColor={theme.muted} + autoCapitalize="none" + /> + + void saveProxy()} + > + {savingProxy ? '保存中…' : '保存'} + + void loadProxy()}> + 重新读取 + + + {!!proxyMsg && {proxyMsg}} + + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + scroll: { padding: 16, paddingBottom: 40 }, + h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 }, + lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 }, + banner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(124,92,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(124,92,255,0.25)', + marginBottom: 14, + }, + bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, + cardNet: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: 'rgba(124,92,255,0.28)', + marginBottom: 14, + }, + cardProxy: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: 'rgba(61,214,140,0.22)', + marginBottom: 14, + }, + cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, + cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text }, + btnSec: { + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bgElevated, + }, + btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 }, + btnPri: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 10, + backgroundColor: '#3d7eff', + }, + btnPriText: { color: '#fff', fontWeight: '700' }, + btnDisabled: { opacity: 0.5 }, + err: { color: theme.danger, marginBottom: 10, fontSize: 13 }, + ok: { color: theme.success, marginTop: 8, fontSize: 13 }, + siteRow: { gap: 12 }, + siteCard: { + padding: 12, + borderRadius: 10, + backgroundColor: theme.bgElevated, + borderWidth: 1, + borderColor: theme.border, + marginBottom: 4, + }, + siteHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, + siteTitle: { fontSize: 14, fontWeight: '600', color: theme.text, flex: 1 }, + badgePending: { fontSize: 11, fontWeight: '700', color: theme.muted }, + badgeOk: { fontSize: 11, fontWeight: '700', color: theme.success }, + badgeBad: { fontSize: 11, fontWeight: '700', color: theme.danger }, + badgeLink: { fontSize: 11, fontWeight: '700', color: theme.accent, textDecorationLine: 'underline' }, + kvCol: { gap: 6 }, + kvRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 8 }, + dim: { opacity: 0.72 }, + k: { fontSize: 12, color: theme.muted, fontWeight: '600', marginTop: 4 }, + v: { fontSize: 13, color: theme.text }, + mono: { fontFamily: 'monospace', fontSize: 12 }, + siteErr: { fontSize: 11, color: theme.warn, marginTop: 8, lineHeight: 16 }, + routesNote: { fontSize: 12, color: theme.muted, marginTop: 10, lineHeight: 18 }, + desc: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 12 }, + rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, + label: { fontSize: 12, color: theme.muted, marginBottom: 6 }, + input: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + padding: 10, + color: theme.text, + marginBottom: 12, + backgroundColor: theme.bg, + }, + inputDisabled: { opacity: 0.6 }, + row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', marginTop: 4 }, +}) diff --git a/app/(tabs)/system.tsx b/app/(tabs)/system.tsx new file mode 100644 index 0000000..a6242ec --- /dev/null +++ b/app/(tabs)/system.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useBind } from '@/context/BindContext' +import { apiFetch } from '@/lib/api' +import type { SystemMetricsPayload } from '@/lib/pcTypes' +import { theme } from '@/constants/theme' + +function fmtBytes(n: number | undefined): string { + if (n == null || Number.isNaN(n)) return '—' + if (n < 1024) return `${n} B` + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB` + return `${(n / 1024 ** 3).toFixed(2)} GB` +} + +export default function SystemScreen() { + const { bind } = useBind() + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + const [refreshing, setRefreshing] = useState(false) + + const load = useCallback(async () => { + if (!bind) { + setData(null) + setErr(null) + return + } + try { + const res = await apiFetch(bind, '/system_metrics') + const json = (await res.json()) as SystemMetricsPayload + if (!res.ok) { + setErr(json.error || `HTTP ${res.status}`) + return + } + setErr(null) + setData(json) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, [bind]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (!bind) return + const id = setInterval(() => void load(), 2000) + return () => clearInterval(id) + }, [bind, load]) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + await load() + setRefreshing(false) + }, [load]) + + const m = data?.memory + const net = data?.network + + return ( + + void onRefresh()} tintColor={theme.accent} /> + ) : undefined + } + > + 系统 + 本机负载与网速(与桌面端「系统」页同源)。开机启动等仅在 Windows 客户端可设置。 + + {!bind && ( + + 未绑定 PC:配对后每 2 秒刷新指标。 + + )} + + + 本机负载与网速 + + 网速为全机网卡合计估算;下方列出检测到的 ffmpeg 进程。 + + {err && bind ? ( + {err} + ) : null} + + + + CPU + {data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'} + 逻辑处理器 {data?.cpu_count_logical ?? '—'} 个 + + + 内存 + {m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'} + + {fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)}) + + + + 全机网速 + + ↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'} + + + ↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'} + + 含所有网卡收发合计 + + + + {data?.swap && data.swap.total != null && data.swap.total > 0 ? ( + + 交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '} + {fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)} + + ) : null} + + {data?.disks && data.disks.length > 0 ? ( + + 磁盘空间 + {data.disks.slice(0, 8).map((d, i) => ( + + {d.mountpoint} ·{' '} + {d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)} + + ))} + + ) : null} + + {data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 ? ( + + ffmpeg 进程 + {data.ffmpeg_processes.map((p, i) => ( + + PID {p.pid} · {p.name ?? '—'} · {fmtBytes(p.rss)} + + ))} + + ) : null} + + + + 客户端与系统集成 + 开机启动、路径与 Electron 集成仅在桌面客户端可用。 + + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + scroll: { padding: 16, paddingBottom: 40 }, + h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 }, + lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 }, + banner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(124,92,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(124,92,255,0.22)', + marginBottom: 14, + }, + bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, + card: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: 'rgba(124,92,255,0.22)', + marginBottom: 12, + }, + cardMuted: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgElevated, + borderWidth: 1, + borderColor: theme.border, + }, + cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 }, + desc: { fontSize: 13, color: theme.muted, marginBottom: 12, lineHeight: 20 }, + err: { color: theme.danger, marginBottom: 10, fontSize: 13 }, + metricsGrid: { gap: 12 }, + block: { + padding: 12, + borderRadius: 10, + backgroundColor: theme.bgElevated, + borderWidth: 1, + borderColor: theme.border, + }, + blockTitle: { fontSize: 11, fontWeight: '700', color: theme.muted, marginBottom: 6 }, + big: { fontSize: 22, fontWeight: '700', color: theme.text }, + sub: { fontSize: 11, color: theme.muted, marginTop: 4, lineHeight: 16 }, + rate: { fontSize: 15, fontWeight: '600', color: theme.accent, fontFamily: 'monospace' }, + swap: { fontSize: 12, color: theme.muted, marginTop: 10, marginBottom: 8 }, + diskSec: { marginTop: 8 }, + diskTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 8 }, + diskLine: { fontSize: 12, color: theme.muted, marginBottom: 6 }, + diskMp: { fontFamily: 'monospace', color: theme.text }, + ffSec: { marginTop: 12 }, + ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' }, + muted: { fontSize: 13, color: theme.muted, lineHeight: 20 }, +}) diff --git a/app/(tabs)/wechat.tsx b/app/(tabs)/wechat.tsx new file mode 100644 index 0000000..58f4c56 --- /dev/null +++ b/app/(tabs)/wechat.tsx @@ -0,0 +1,392 @@ +import { useCallback, useEffect, useState } from 'react' +import { + View, + Text, + ScrollView, + StyleSheet, + TextInput, + Pressable, + Image, + Linking, + RefreshControl, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useBind } from '@/context/BindContext' +import { apiFetch } from '@/lib/api' +import { theme } from '@/constants/theme' + +type WechatConfigView = { + configured: boolean + proxyUrl: string + apiKeyMasked: string + deviceType: string + proxy: string + webhookHost: string + webhookPort: number + webhookPath: string + accountId: string + session: { wcId?: string; nickName?: string } +} + +export default function WeChatScreen() { + const { bind } = useBind() + const [cfg, setCfg] = useState(null) + const [msg, setMsg] = useState('') + const [busy, setBusy] = useState(false) + const [apiKeyInput, setApiKeyInput] = useState('') + const [proxyUrlInput, setProxyUrlInput] = useState('') + const [deviceType, setDeviceType] = useState('ipad') + const [proxyLine, setProxyLine] = useState('10') + const [wId, setWId] = useState('') + const [qrUrl, setQrUrl] = useState('') + const [poll, setPoll] = useState(false) + const [proxyStatus, setProxyStatus] = useState | null>(null) + const [refreshing, setRefreshing] = useState(false) + + const load = useCallback(async () => { + if (!bind) { + setCfg(null) + return + } + setMsg('') + try { + const r = await apiFetch(bind, '/chatbot/wechat/config') + if (!r.ok) throw new Error(String(r.status)) + const j = (await r.json()) as WechatConfigView + setCfg(j) + setProxyUrlInput(j.proxyUrl || '') + setDeviceType(j.deviceType || 'ipad') + setProxyLine(j.proxy || '10') + setApiKeyInput('') + } catch (e) { + setCfg(null) + setMsg(e instanceof Error ? e.message : String(e)) + } + }, [bind]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (!poll || !wId || !bind) return + const tick = async () => { + try { + const r = await apiFetch(bind, '/chatbot/wechat/login/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wId }), + }) + const j = (await r.json()) as { + status?: string + wcId?: string + nickName?: string + verifyUrl?: string + error?: string + } + if (!r.ok) { + setMsg(j.error || String(r.status)) + setPoll(false) + return + } + if (j.status === 'logged_in') { + setPoll(false) + setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`) + void load() + return + } + if (j.status === 'need_verify' && j.verifyUrl) { + setMsg(`需要辅助验证:${j.verifyUrl}`) + } + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + setPoll(false) + } + } + const id = setInterval(() => void tick(), 5000) + void tick() + return () => clearInterval(id) + }, [poll, wId, bind, load]) + + const saveConfig = async () => { + if (!bind) return + setBusy(true) + setMsg('') + try { + const body: Record = { + proxyUrl: proxyUrlInput.trim(), + deviceType, + proxy: proxyLine.trim(), + } + if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim() + const r = await apiFetch(bind, '/chatbot/wechat/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!r.ok) { + const t = await r.text() + throw new Error(t || String(r.status)) + } + await load() + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const loadProxyStatus = async () => { + if (!bind) return + setBusy(true) + setMsg('') + try { + const r = await apiFetch(bind, '/chatbot/wechat/proxy/status') + const j = await r.json() + if (!r.ok) { + setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j)) + setProxyStatus(null) + return + } + setProxyStatus(j as Record) + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const startQr = async () => { + if (!bind) return + setBusy(true) + setMsg('') + setQrUrl('') + setWId('') + try { + const r = await apiFetch(bind, '/chatbot/wechat/qr/start', { method: 'POST' }) + const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string } + if (!r.ok) { + setMsg(j.error || String(r.status)) + return + } + if (!j.wId || !j.qrCodeUrl) { + setMsg('未返回二维码') + return + } + setWId(j.wId) + setQrUrl(j.qrCodeUrl) + setPoll(true) + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const onRefresh = useCallback(async () => { + setRefreshing(true) + await load() + setRefreshing(false) + }, [load]) + + return ( + + void onRefresh()} tintColor={theme.accent} /> + ) : undefined + } + > + 微信 + 与桌面端「微信」页同源:openclaw-wechat 代理 API。 + + {!bind && ( + + 未绑定 PC:配对后可读写配置与扫码登录。 + + )} + + + 微信 Chatbot + proxyUrl(代理服务根地址) + + apiKey(留空则不修改已保存密钥) + + deviceType + + setDeviceType('ipad')} + disabled={!bind || busy} + > + ipad + + setDeviceType('mac')} + disabled={!bind || busy} + > + mac + + + proxy 参数 + + + + void saveConfig()}> + 保存配置 + + void load()}> + 刷新 + + void loadProxyStatus()} + > + 查询代理状态 + + + + {proxyStatus ? ( + {JSON.stringify(proxyStatus, null, 2)} + ) : null} + + {cfg?.session?.wcId ? ( + 本地记录:{cfg.session.nickName || '—'}({cfg.session.wcId}) + ) : null} + + void startQr()} + > + 获取登录二维码 + + {!cfg?.configured && bind ? ( + 请先填写并保存 apiKey 与 proxyUrl。 + ) : null} + + {qrUrl ? ( + + 请使用微信扫描下方二维码。 + + void Linking.openURL(qrUrl)}> + 打开二维码链接 + + + ) : null} + + {!!msg && ( + {msg} + )} + + + + ) +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bg }, + scroll: { padding: 16, paddingBottom: 40 }, + h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 }, + lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 }, + banner: { + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(100,149,237,0.1)', + borderWidth: 1, + borderColor: 'rgba(100,149,237,0.25)', + marginBottom: 14, + }, + bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, + card: { + padding: 14, + borderRadius: 12, + backgroundColor: theme.bgCard, + borderWidth: 1, + borderColor: 'rgba(100,149,237,0.28)', + }, + cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 12 }, + label: { fontSize: 12, color: theme.muted, marginBottom: 6 }, + input: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: 10, + padding: 10, + color: theme.text, + marginBottom: 12, + backgroundColor: theme.bg, + }, + inputDisabled: { opacity: 0.55 }, + row: { flexDirection: 'row', gap: 10, marginBottom: 12 }, + chip: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bgElevated, + }, + chipOn: { borderColor: theme.accent, backgroundColor: 'rgba(91,157,255,0.12)' }, + chipText: { color: theme.text, fontWeight: '600' }, + btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 }, + btnSec: { + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bgElevated, + }, + btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 }, + btnPri: { + marginTop: 8, + paddingVertical: 12, + borderRadius: 10, + backgroundColor: '#3d7eff', + alignItems: 'center', + }, + btnPriText: { color: '#fff', fontWeight: '700' }, + disabled: { opacity: 0.45 }, + pre: { + fontFamily: 'monospace', + fontSize: 11, + color: '#c8cdd5', + marginBottom: 12, + lineHeight: 16, + }, + hint: { fontSize: 13, color: theme.muted, marginTop: 8, lineHeight: 20 }, + qrBlock: { marginTop: 16, alignItems: 'center' }, + qrImg: { width: 224, height: 224, marginVertical: 12, backgroundColor: '#fff' }, + link: { color: theme.accent, fontSize: 14, textDecorationLine: 'underline' }, + msg: { marginTop: 12, fontSize: 14 }, + msgOk: { color: theme.success }, + msgErr: { color: theme.danger }, +}) diff --git a/app/+html.tsx b/app/+html.tsx new file mode 100644 index 0000000..cb31090 --- /dev/null +++ b/app/+html.tsx @@ -0,0 +1,38 @@ +import { ScrollViewStyleReset } from 'expo-router/html'; + +// This file is web-only and used to configure the root HTML for every +// web page during static rendering. +// The contents of this function only run in Node.js environments and +// do not have access to the DOM or browser APIs. +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {/* + Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. + However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} +