Compare commits

10 Commits

Author SHA1 Message Date
fabb48eab6 Merge branch 'feature/client-api-integration' 2026-08-20 10:21:54 +08:00
d65c30a6fb docs: 修改 TURN 部署指南并新增环境说明 2026-08-20 09:50:29 +08:00
48dc443a31 docs: 补充JSON序列化选型规范、ViewModel规则及项目目录结构 2026-08-04 18:13:14 +08:00
TongTongStudio
2b6fc78334 docs(WebRTCController): 添加 AI 协作规则与开发规范文档
新增 AGENTS.md,定义 Android 控制端工程的 AI 协作规则、技术栈、架构分层、编码规范、协议一致性约束及安全配置要求。
2026-08-04 02:52:23 +08:00
TongTongStudio
9b0e105647 fix: 修复 WebRTC 控制稳定性与输入事件重复问题
- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行
- 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复
- 修复 Web 端指针取消时未释放触摸状态导致被控端卡死
- 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false
- 修复后台管理端 401 后路由守卫跳转失败
- 增加控制指令调试日志用于排查问题
2026-08-04 02:40:23 +08:00
1bd0318d35 feat(ios): add online device list and pairing code redemption
- Add API methods for fetching online devices and redeeming pairing codes
- Add OnlineDevice model and corresponding ViewModel state management
- Add UI sections for online device list and pairing code input in SetupPanel
- Ensure access token is verified before connecting to signaling server
2026-08-03 21:42:31 +08:00
da0a3bf59d chore(ios): 添加LoginView并修复TokenStore类型与线程调度问题
- 在Xcode项目中添加LoginView.swift文件引用
- 修复TokenStore中Keychain查询字典类型为CFString
- 将DispatchQueue.main.async替换为MainActor.run,避免弱引用丢失
- 修复绑定设备信息时的线程安全与变量命名
2026-08-03 20:51:16 +08:00
5e369f14d9 feat: 添加 ApiClient 和 TokenStore 工具类 2026-08-03 20:44:49 +08:00
TongTongStudio
e1de4c8170 feat(remote): 修复键盘输入映射并重构连接流程
- Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题
- Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发
- 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换
- AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法
- DioAuthRepository实现401自动刷新token重试机制
- ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段
2026-08-03 20:22:29 +08:00
TongTongStudio
7bb44b10be fix(webrtc): 修复 ICE 服务器解析逻辑,支持自定义端口与标准参数
修正 ice_servers.dart 中的 URL 解析,使 stun/turn 地址能正确识别自定义端口及 transport/port 参数;同步调整 webrtc_controller.dart 中的调用方式以适配新解析逻辑。
2026-08-02 11:47:10 +08:00
38 changed files with 2365 additions and 778 deletions

View File

@@ -180,6 +180,34 @@
- 签名文件与密码通过 `config.gradle` / CI 环境变量注入,不进版本库。
- 客户端凭据一律加密存储Android: security-cryptoFlutter: secure_storageiOS: Keychain
### JSON / 序列化库选型规范
- **禁止使用 fastjson**。
- 各平台按以下原则选型:
| 平台 / 语言 | 推荐库 | 备选 |
|---|---|---|
| Spring BootJava | **Jackson**`spring-boot-starter-web` 已内置) | — |
| AndroidJava | **Gson** | Jackson体积稍大但功能更强 |
| AndroidKotlin | **kotlinx.serialization** | Moshi已有技术积累的项目可沿用 |
| 纯后端 Kotlin非 Spring Boot | **kotlinx.serialization** | Jackson |
| iOSSwift | **Codable**(系统原生) | — |
| FlutterDart | **json_annotation + json_serializable + freezed** | — |
- **例外流程**:若因第三方依赖必须使用其他库,需提交技术评审并锁定版本、开启安全扫描。
### ViewModel 规则
* **绝不持有 Context**ViewModel 中**严格禁止**持有 `Context``Activity``Fragment``View` 的引用。若需要 `Application Context`,请继承 `AndroidViewModel`(但优先推荐使用 Hilt 注入所需依赖)。
* **数据暴露规范**
* 对外只暴露不可变的 `LiveData<T>`
* 内部维护私有的 `MutableLiveData<T>`
* 示例:
```java
private final MutableLiveData<UserUiState> _uiState = new MutableLiveData<>();
public LiveData<UserUiState> getUiState() {
return _uiState;
}
```
### 代码修改
- 优先做**最小化定向修改**,不要对既有大文件做整体重写或重构。
- 修改前先读取文件当前内容,避免基于陈旧上下文编辑。

141
README.md
View File

@@ -1,36 +1,127 @@
安装 turnserver
# VibeCoding — WebRTC 远程控制系统
sudo apt update
sudo apt install coturn -y
本仓库为多端单体仓库monorepo1 个 Spring Boot 信令服务器 + 1 个后台管理前端 + 1 个 Android 被控端 + 5 个控制端Android / iOS / Flutter / Web+ 1 个独立 ADB 回环工具。
sudo cp /etc/turnserver.conf /etc/turnserver.conf.bak
sudo vim /etc/turnserver.conf
> 标注 **(待填写)** 的项表示尚未确认,请按实际环境补充。
# 监听
listening-ip=0.0.0.0
listening-port=3478
---
# 公网 IP云服务器这里必须填公网 IP不是内网 IP
external-ip=你的公网IP
## 一、WebRTCSignalServer — 信令服务器Spring Boot
# 认证WebRTC 必须,否则客户端连不上)
lt-cred-mech
user=testuser:testpass
realm=yourdomain.com # 随便填个域名也行,但不能不填
| 项目 | 值 |
|---|---|
| 工程类型 | Spring Boot 应用Maven |
| 框架版本 | Spring Boot 4.1.0 |
| 语言 / JDK | Java 25 |
| IDE | IntelliJ IDEA 2025.3.1 |
| 运行端口 | 8080 |
| 外部依赖 | MySQLJPA 持久化、Redis在线状态 / 频控 / nonce / 踢线广播) |
| 构建命令 | `mvn clean package` / `mvn spring-boot:run` |
| 配置入口 | `src/main/resources/application.yml`(敏感项走环境变量) |
# 中继端口范围TURN 转发媒体用
relay-port-range=49152-65535
## 二、WebRTCSignalServerWeb — 后台管理前端Vue3 + TS
# 日志
verbose
| 项目 | 值 |
|---|---|
| 工程类型 | Vue 3 SPATypeScriptESM |
| Vue | ^3.5.12 |
| TypeScript | ^5.6.3 |
| Vite | ^5.4.10 |
| Pinia / Vue Router | ^2.2.4 / ^4.4.5 |
| UI 组件库 | Element Plus ^2.8.4 |
| Node.js | 24.14.1 |
| 包管理器 | npm |
| dev 端口 | 5180 |
| 命令 | `npm run dev` / `npm run build`(含 `vue-tsc --noEmit`/ `npm run preview` |
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp # WebRTC 可选但建议
sudo ufw allow 49152:65535/udp # TURN 中继端口
## 三、WebRTCControlled — Android 被控端
sudo systemctl restart coturn
sudo systemctl enable --now coturn
systemctl status coturn # 看是不是 active (running)
| 项目 | 值 |
|---|---|
| 语言 | JavaJava 8 |
| IDE | Android Studio Panda 4 \| 2025.3.4 Patch 1 |
| applicationId | `com.ttstd.controlled` |
| compileSdk | 34 |
| minSdk | 24 |
| targetSdk | 34 |
| Gradle Wrapper | 8.13 |
| Android Gradle Plugin | 8.13.2 |
| JDK 版本 | 21 |
| 签名配置 | 依赖外部 `config.gradle``keypub` / `crosshatch` / `zhanxun`),不在仓库内 |
| 构建命令 | `gradlew.bat assembleDebug`Windows/ `./gradlew assembleDebug` |
turnutils_uclient -v -u testuser -w testpass 你的公网IP
## 四、WebRTCController — Android 控制端
| 项目 | 值 |
|---|---|
| 语言 | JavaJava 8 |
| IDE | Android Studio Panda 4 \| 2025.3.4 Patch 1 |
| applicationId | `com.ttstd.controller` |
| compileSdk | 34 |
| minSdk | 24 |
| targetSdk | 34 |
| Gradle Wrapper | 8.13 |
| Android Gradle Plugin | 8.13.2 |
| JDK 版本 | 21 |
| 签名配置 | 同被控端,依赖外部 `config.gradle` |
| 构建命令 | `gradlew.bat assembleDebug`Windows/ `./gradlew assembleDebug` |
## 五、webrtc_controller_flutter — Flutter 控制端(跨平台)
| 项目 | 值 |
|---|---|
| 工程类型 | Flutter AppAndroid + iOS |
| Dart SDK | ^3.12.2 |
| IDE | Android Studio Panda 4 \| 2025.3.4 Patch 1 |
| Flutter 版本 | 待填写channel: stable |
| Android compileSdk / minSdk / targetSdk | 跟随 Flutter 默认(`flutter.compileSdkVersion` / `flutter.minSdkVersion` / `flutter.targetSdkVersion`,具体值待确认) |
| Android 编译兼容 | Java 17jvmTarget 17 |
| iOS Deployment Target | 13.0 |
| iOS Swift 版本 | 5.0 |
| Gradle Wrapper | 8.14 |
| 依赖管理 | pub核心`flutter_webrtc ^1.5.2` |
| 命令 | `flutter pub get` / `flutter run` / `flutter analyze` |
## 六、webrtc_controller_ios — 原生 iOS 控制端
| 项目 | 值 |
|---|---|
| 工程类型 | iOS AppUIKit + SwiftUI 混合) |
| 语言 | Swift 5.0 |
| iOS Deployment Target | 15.0 |
| Xcode 版本 | 16 |
| 依赖管理 | Swift Package Manager无 CocoaPods |
| 关键依赖 | WebRTCSPM 远程包) |
| 构建方式 | 用 Xcode 打开 `webrtc_controller_ios.xcodeproj` 运行 |
## 七、WebRTCControllerWeb — 网页控制端Vue3 + JS
| 项目 | 值 |
|---|---|
| 工程类型 | Vue 3 SPA纯 JavaScript无 TypeScript |
| Vue | ^3.4.21 |
| Vite | ^5.2.0 |
| 控制指令编码 | protobufjs ^7.3.2(浏览器原生 WebRTC API |
| Node.js | v24.14.1 |
| 包管理器 | npm |
| dev 端口 | 5173`/api``/ws/signal` 代理到信令服务器) |
| 命令 | `npm run dev` / `npm run build` / `npm run preview` |
## ~~八、AdbLoopbackController — ADB 回环控制工具(独立)~~
| 项目 | 值 |
|---|---|
| 工程类型 | Android App纯 Java |
| 语言 | JavaJava 8 |
| IDE | Android Studio Panda 4 \| 2025.3.4 Patch 1 |
| applicationId | `com.ttstd.adbloopback` |
| compileSdk | 34 |
| minSdk | 24 |
| targetSdk | 34 |
| Gradle Wrapper | 8.13 |
| Android Gradle Plugin | 8.1.4 |
| JDK 版本 | 21 |
| 签名配置 | 依赖外部 `config.gradle``keypub` |
| 构建命令 | `gradlew.bat assembleDebug`Windows/ `./gradlew assembleDebug` |
---

View File

@@ -117,7 +117,12 @@ public class SystemInputUtils implements InputExecutor {
}
private void injectKeyEvent(long downTime, long eventTime, int action, int keyCode) {
// 第 5 个参数 metaState=0最后一个参数 flags=0。
// 关键必须显式设置事件来源为键盘SOURCE_KEYBOARD否则通过 InputManager
// 注入的按键事件会被系统/输入法IME丢弃导致焦点在 EditText 等输入框时
// 收不到字符输入。
KeyEvent event = new KeyEvent(downTime, eventTime, action, keyCode, 0);
event.setSource(InputDevice.SOURCE_KEYBOARD);
try {
mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC);
} catch (Exception e) {

184
WebRTCController/AGENTS.md Normal file
View File

@@ -0,0 +1,184 @@
# AGENTS.md — WebRTCControllerAndroid 控制端)
本文件是 `WebRTCController/` 工程的 **AI 协作规则与开发规范**。它是仓库根 `AGENTS.md` 中 Android 控制端条目的细化与补充,**当两者冲突时以本文件为准**(但安全与协议一致性约束始终以根文件最高优先级为准)。
---
## 一、工程定位
| 项 | 值 |
|---|---|
| 角色 | **控制端**(观看被控端投屏 + 通过 DataChannel 下发控制指令) |
| 应用 ID / namespace | `com.ttstd.controller` |
| 主语言 | **Java**(全部源码为 `.java`Kotlin 插件仅用于注解处理,禁止新增 Kotlin 源文件) |
| 语言级别 | Java 8`sourceCompatibility/targetCompatibility = VERSION_1_8``jvmTarget = 1.8` |
| SDK | `compileSdk 34` / `minSdk 24` / `targetSdk 34` |
| 构建系统 | Gradle + AGP `8.13.2`Kotlin 插件仅作注解处理器) |
| 构建变体 | `debug` / `release` / `zhanRuiDebug` / `zhanRuiRelease` / `CrosshatchDebug` / `CrosshatchRelease` |
| 协议通道 | 信令 = WebSocket + **JSON**;控制指令 = WebRTC DataChannel + **Protobuf 二进制** |
---
## 二、技术栈(当前已采用,遵循 Google 推荐与业内标准)
### 2.1 官方 Jetpack 与 Google 标准库
- **架构组件**`androidx.lifecycle`ViewModel / LiveData / LifecycleService`androidx.room`(本地持久化)、`androidx.appcompat` + `com.google.android.material`Material Design 3 基础组件)。
- **安全存储**`androidx.security:security-crypto:1.1.0`,用于加密保存 `deviceSecret` / `deviceUid` / `accessToken`。**绝不明文 SharedPreferences 或硬编码**。
- **并发 / 异步**`io.reactivex.rxjava3`RxJava3 + RxAndroid配合 `com.trello.rxlifecycle4` 做生命周期自动解绑。禁止裸 `Thread` / `AsyncTask`
- **图片加载**`com.github.bumptech.glide:4.15.1`(含 `kapt` 注解处理器)。
- **轻量 KV**`com.tencent:mmkv-static:2.4.0`(仅用于非敏感运行态缓存;敏感数据必须用 security-crypto
### 2.2 网络与序列化
- **HTTP / REST**`Retrofit 3.0.0` + `OkHttp 5.3.2``logging-interceptor`+ `converter-gson` + `adapter-rxjava3`
- **JSON**`Gson 2.14.0`(信令消息 JSON 解析)。
- **Protobuf**`com.google.protobuf:protobuf-java:3.25.1`,由 `com.google.protobuf` Gradle 插件按 `*.proto` 生成 Java 类DataChannel 控制指令二进制)。
- **WebRTC**`io.github.webrtc-sdk:android:144.7559.09`
### 2.3 构建与代码生成
- **DataBinding**`buildFeatures.dataBinding = true`(视图绑定统一用 DataBinding 或 ViewBinding避免 `findViewById`)。
- **BuildConfig**`API_BASE`HTTP 基址)、`WS_URL`(信令 WebSocket 地址),均为开发占位值,部署时由 flavor / CI 注入。
- **AIDL**`buildFeatures.aidl = true`(如需跨进程通信)。
> 注:仓库当前使用 `moshi` 依赖但未实际用于信令/指令编解码(信令用 Gson、指令用 Protobuf。新增 JSON 解析请优先统一到 Gson避免双序列化栈。
---
## 三、推荐目录结构Google 应用架构指南 + 现代 Android 开发)
采用 **单一 Activity + 多 FragmentNavigation Component** 的应用结构,按 **UI / Domain / Data** 分层。源码位于 `app/src/main/java/com/ttstd/controller/` 下,建议如下布局:
```
com.ttstd.controller
├── App.java // Application 子类:初始化 MMKV、Room、全局依赖
├── ui/ // 表现层 UI (按 Feature 分包)
│ ├── base/ // 基类
│ ├── main/ // 主功能
│ │ ├── MainActivity.java
│ │ ├── MainViewModel.java
│ │ └── MainUiState.java
│ ├── home/ // 首页 / 设备列表
│ │ ├── HomeFragment.java
│ │ └── HomeViewModel.java
│ ├── control/ // 远程控制界面(投屏观看 + 触控下发)
│ │ ├── ControlFragment.java
│ │ └── ControlViewModel.java
│ ├── login/ // 激活 / 登录
│ └── common/ // 共享 UIBaseFragment、适配器、自定义 View
├── domain/ // 领域层(可选,纯 Java 用例 / 业务规则)
│ ├── model/ // 业务模型(与 data 层实体解耦的纯对象)
│ └── usecase/ // 交互器(封装单一业务动作)
├── data/ // 数据层
│ ├── local/ // Room实体、DAO、Database、MMKV、security-crypto 封装
│ │ ├── db/ // @Entity / @Dao / @Database
│ │ ├── secure/ // EncryptedSharedPreferences 封装(凭据存储)
│ │ └── pref/ // MMKV 封装(非敏感)
│ ├── remote/ // 网络Retrofit API 接口、WebSocket 信令客户端)
│ │ ├── api/ // REST 接口定义
│ │ ├── model/
│ │ └── signaling/ // WebSocket 信令连接与消息收发
│ ├── signaling/ // 信令消息JSON数据类 / 解析
│ ├── proto/ // 由 app/src/main/proto/*.proto 生成的指令类 + 编解码封装
│ └── repository/ // 仓储:向 UI 暴露单一数据源(组合 local + remote
├── webrtc/ // WebRTC 封装
│ ├── PeerConnectionClient.java // RTCPeerConnection / SDP / ICE 管理
│ ├── DataChannelManager.java // 控制指令收发Protobuf 编码)
│ ├── WebSocketSignalingClient.java// 信令JSON
│ └── IceServerProvider.java // ICE 服务器配置
├── controller/ // 远端控制指令业务逻辑
│ ├── CommandDispatcher.java // 指令分发TOUCH / SWIPE / KEY 等)
│ └── ControlCommandFactory.java // 构造 Protobuf 控制指令
├── service/ // 前台 / 后台 Service如保活、长连接
│ └── SignalingService.java
├── receiver/ # 全局 BroadcastReceiver系统级广播
│ ├── BootCompletedReceiver.java # 开机启动
│ └── NetworkChangeReceiver.java # 网络变化
└── utils/ // 工具类(线程、日志、权限、网络状态)
```
**资源目录**(已存在 `app/src/main/res/`
- `layout/`Fragment/Activity 布局DataBinding 布局以 `fragment_xxx.xml` / `activity_xxx.xml` 命名)。
- `navigation/`NavGraph`nav_graph.xml`,单一 Activity 路由)。
- `values/``drawable/``mipmap-*``xml/`(网络/备份配置)等按 Android 规范组织。
**Proto 目录**`app/src/main/proto/*.proto`(与 `WebRTCControlled` 及 Flutter / iOS / Web 端字段号一致)。
### 分层约束Google 架构基线)
- **单向数据流**UI 不直接访问 `remote`/`local`,统一经 `repository`
- **ViewModel 持有状态**:用 `LiveData`/`RxJava` 暴露可观察数据UI 只负责渲染与事件上报。
- **依赖方向**`ui → domain → data``data` 不反向依赖 `ui`
- 不要在 `Activity`/`Fragment` 中直接持有 `RTCPeerConnection`WebRTC 逻辑收敛到 `webrtc/` 包。
---
## 四、编码规范Google Java Style + Android 实践)
1. **Java 8 限制**:禁止 `List.of``var`、switch 表达式等 Java 9+ API除非确认脱糖支持
2. **架构模式**UI 用 MVVMViewModel + LiveData / RxJava单 Activity + Navigation。
3. **视图绑定**:使用 DataBinding / ViewBinding禁止 `findViewById` 裸用。
4. **异步**:所有后台任务走 RxJava3`.compose(rxlifecycle)` 绑定生命周期,避免内存泄漏。
5. **网络请求**:统一走 Retrofit 接口 + OkHttp 拦截器JSON 用 GsonDataChannel 指令用 Protobuf。
6. **敏感数据**`deviceSecret` / `deviceUid` / `accessToken` 一律存 `security-crypto` 加密 SP禁止明文。
7. **线程**:主线程只做 UIIO / 网络 / 编解码放 `Schedulers.io()` / 子线程。
8. **日志**:使用统一日志工具(带 TAG发版变体关闭敏感信息输出。
---
## 五、协议一致性(最高优先级,跨端约束)
本系统为 **1 被控端 + 5 控制端**,协议改动影响面极大:
- 信令通道WebSocket + **JSON**控制指令通道WebRTC DataChannel + **Protobuf 二进制**。两者不可混用。
- 修改 `*.proto` 或信令 JSON 字段,**必须同步**`WebRTCControlled``WebRTCController``webrtc_controller_flutter``webrtc_controller_ios``WebRTCControllerWeb``WebRTCSignalServer`
- Protobuf 字段号**只增不改不删**;废弃字段用 `reserved` 标记。
- 指令类型TOUCH / SWIPE / KEY 等)新增时,在所有端补齐分支处理(`CommandDispatcher` 与各端解析处)。
---
## 六、安全与配置
- 禁止在源码、`build.gradle``config.gradle``local.properties` 中硬编码真实密钥、证书口令、生产地址。
- 现有占位值(`http://192.168.5.224:8080``ws://192.168.5.224:8080/ws/signal`、签名密码 `123456`/`Fan1996`)为开发默认值,**不要当作正式配置扩散**。
- 签名文件与密码通过 `config.gradle`(外部)注入,不进版本库;`config.gradle` 已含 `keypub`/`crosshatch`/`zhanxun` 三套签名。
- 客户端凭据一律加密存储(本工程:`androidx.security.crypto`)。
---
## 七、构建与变体
- **构建**`gradle assembleDebug` / `gradle assembleRelease`;产物名由 `appName()``WebRTCController`+ 版本 + 日期生成。
- **变体说明**
- `debug` / `release`:公开签名 `keypub`
- `zhanRui*`:展锐平台,使用 `zhanxun` 签名(系统签名场景)。
- `Crosshatch*`Crosshatch 设备,使用 `crosshatch` 签名(系统签名场景)。
- 修改 `appName()` 返回值会影响既有 CI 产物名,**先确认**。
- 修改签名配置、变体名或系统签名相关(`src/system/AndroidManifest.xml` 等)前**必须先确认**,涉及系统签名固件适配。
---
## 八、代码修改通用规则
- 优先做**最小化定向修改**,不对既有大文件整体重写或重构。
- 修改前先 `read_file` 当前内容,避免基于陈旧上下文编辑。
- 不主动创建 `*.md` 文档、示例文件或临时脚本;临时产物用完即删。
- Room 实体/DAO 变更**必须提供 Migration**,禁止 `fallbackToDestructiveMigration`
- 编辑后修复自己引入的 lint / 编译错误。
- 注释与提交说明使用**简体中文**。
- 新增第三方依赖前说明必要性,并锁定版本;不擅自升级 AGP / Gradle / 大版本库。
- 不在 Java 工程中混入 Kotlin 源码Kotlin 插件仅用于注解处理)。
---
## 九、禁止事项
- 不改动根目录 `AGENTS.md``vue-vben-admin-origin/`
- 不删除 `.codebuddy/` 目录。
- 不提交构建产物(`build/``*.class``*.dex``*.flat``*.dumpstream` 等)。
- 不引入 CocoaPods / 其他平台专属工具链到 Android 工程。

View File

@@ -214,10 +214,11 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
protected void initData() {
// 任意 REST 接口 401 -> 直接回登录页(与 WebSocket 4001/4003 行为一致)。
UnauthorizedInterceptor.setHandler(msg -> {
// 注意:该回调运行在 OkHttp 拦截器线程,必须切回主线程再操作 Toast / UI。
UnauthorizedInterceptor.setHandler(msg -> runOnUiThread(() -> {
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
logoutAndReset();
});
}));
// 令牌就绪 -> 建立信令 WebSocket
viewModel.getAccessToken().observe(this, token -> {
@@ -303,7 +304,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
loadDevices();
}
/** 合并设备列表(在线设备优先置于顶部),用于列表展示。 */
/**
* 合并设备列表(在线设备优先置于顶部),用于列表展示。
*/
private void mergeDevices(List<BindingItem> items) {
for (BindingItem item : items) {
int idx = -1;
@@ -323,7 +326,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
deviceList.sort((a, b) -> Boolean.compare(b.isOnline(), a.isOnline()));
}
/** 设备列表为空时显示占位提示。 */
/**
* 设备列表为空时显示占位提示。
*/
private void updateDeviceEmpty() {
boolean empty = deviceList.isEmpty();
binding.tvDeviceEmpty.setVisibility(empty ? View.VISIBLE : View.GONE);

View File

@@ -29,11 +29,19 @@ const selectedResolution = ref(0);
const fpsOptions = ref([15, 24, 30, 60]);
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
// 记录当前处于按下状态的按键pointerleave/pointerup 在指针未按下时也会触发,
// 若无此跟踪,鼠标仅扫过按钮就会向被控端发送孤立的 KEY UP。
const pressedButtons = new Set();
function pressDown(code) {
if (!store.dataChannelOpen) return;
if (pressedButtons.has(code)) return;
pressedButtons.add(code);
sendKeyDown(code);
}
function pressUp(code) {
if (!pressedButtons.has(code)) return;
pressedButtons.delete(code);
if (!store.dataChannelOpen) return;
sendKeyUp(code);
}
@@ -71,18 +79,26 @@ function lookupKeyCode(e) {
}
// 固定按键:按下/抬起分离(如电源键需要真实按下-抬起配对)
// pressedKeys 跟踪已按下的物理键,避免长按自动重复触发重复 DOWN、
// 以及无对应 DOWN 的孤立 UP如在窗口外按下、回到窗口内才抬起
const pressedKeys = new Set();
function onKeyCaptureDown(e) {
if (!store.dataChannelOpen) return;
const m = lookupKeyCode(e);
if (!m) return;
e.preventDefault();
if (pressedKeys.has(m.code)) return; // 浏览器长按自动重复,忽略
pressedKeys.add(m.code);
sendKeyDown(m.code);
}
function onKeyCaptureUp(e) {
if (!store.dataChannelOpen) return;
const m = lookupKeyCode(e);
if (!m) return;
e.preventDefault();
if (!pressedKeys.has(m.code)) return;
pressedKeys.delete(m.code);
if (!store.dataChannelOpen) return;
sendKeyUp(m.code);
}

View File

@@ -45,6 +45,9 @@ const TOUCH_SLOP = 0.02;
const SAMPLE_MS = 16;
let startX = 0, startY = 0, startTime = 0, isLongPressed = false, longTimer = null, lastMoveTs = 0;
// 指针是否处于按下状态:未按下时 pointermove/pointerup 不得产生任何指令,
// 否则鼠标仅悬停划过画面就会向被控端发送大量 ACTION_MOVE。
let isDown = false;
function clearLong() {
if (longTimer) { clearTimeout(longTimer); longTimer = null; }
@@ -53,6 +56,7 @@ function clearLong() {
function onDown(e) {
if (!active.value) return;
overlayRef.value.setPointerCapture && overlayRef.value.setPointerCapture(e.pointerId);
isDown = true;
isLongPressed = false;
const p = mapRelative(e.clientX, e.clientY);
startX = p.x; startY = p.y; startTime = Date.now(); lastMoveTs = 0;
@@ -65,7 +69,7 @@ function onDown(e) {
}
function onMove(e) {
if (!active.value) return;
if (!active.value || !isDown) return;
const p = mapRelative(e.clientX, e.clientY);
if (Math.abs(p.x - startX) > TOUCH_SLOP || Math.abs(p.y - startY) > TOUCH_SLOP) clearLong();
const now = Date.now();
@@ -76,7 +80,8 @@ function onMove(e) {
}
function onUp(e) {
if (!active.value) return;
if (!active.value || !isDown) return;
isDown = false;
clearLong();
const p = mapRelative(e.clientX, e.clientY);
sendMotionEvent(1, p.x, p.y); // ACTION_UP
@@ -88,6 +93,15 @@ function onUp(e) {
else sendSwipe(startX, startY, p.x, p.y, Math.max(dur, 1));
}
}
function onCancel() {
clearLong();
// 按下过程中指针被取消(如系统手势接管):补发 ACTION_UP 释放,避免被控端卡在按下态。
if (isDown && active.value) {
isDown = false;
sendMotionEvent(1, startX, startY); // ACTION_UP
}
}
</script>
<template>
@@ -101,7 +115,7 @@ function onUp(e) {
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="clearLong"
@pointercancel="onCancel"
></div>
<div v-if="!hasStream" class="placeholder">

View File

@@ -222,6 +222,7 @@ export class WebRtcController {
if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false;
const bytes = encodeControlMessage(fields);
this.dataChannel.send(bytes);
console.debug('[控制指令]', describeControlMessage(fields), `bytes=${bytes.length}`);
return true;
}
@@ -327,6 +328,26 @@ export class WebRtcController {
}
}
// 将待发送的控制指令格式化为可读日志文本(坐标保留 4 位小数)。
function describeControlMessage(f) {
const pt = (x, y) => `(${Number(x).toFixed(4)}, ${Number(y).toFixed(4)})`;
switch (f.action) {
case 1: return `模拟点击 TOUCH ${pt(f.x, f.y)}`;
case 2: return `模拟滑动 SWIPE ${pt(f.x1, f.y1)} -> ${pt(f.x2, f.y2)} duration=${f.duration}ms`;
case 3: {
const ka = f.keyAction === 0 ? 'DOWN' : f.keyAction === 1 ? 'UP' : 'CLICK';
return `模拟按键 KEY keyCode=${f.keyCode} keyAction=${ka}`;
}
case 4: return `模拟长按 LONG_PRESS ${pt(f.x, f.y)}`;
case 5: {
const ma = f.motionAction === 0 ? 'DOWN' : f.motionAction === 1 ? 'UP' : f.motionAction === 2 ? 'MOVE' : String(f.motionAction);
return `触摸轨迹 MOTION ${ma} ${pt(f.x, f.y)}`;
}
case 6: return `切换分辨率 SET_RESOLUTION ${f.width}x${f.height}@${f.fps}fps`;
default: return `未知指令 action=${f.action}`;
}
}
// 将字节/秒格式化为可读速率
function formatSpeed(bps) {
if (!bps || bps <= 0) return '0 B/s';

View File

@@ -0,0 +1,154 @@
# WebRTC 信令服务器 · TURN 部署指南
本项目使用 **coturn** 作为 TURN/STUN 中继服务器,用于解决 WebRTC 在对称型 NAT / 防火墙等复杂网络环境下的连通性问题。
> 当客户端无法直接进行 P2P 连接时,媒体流量将通过 TURN 服务器进行中继转发。
---
## 目录
- [环境说明](#环境说明)
- [一、安装 coturn](#一安装-coturn)
- [二、配置 TURN 服务](#二配置-turn-服务)
- [三、开放防火墙端口](#三开放防火墙端口)
- [四、启动与开机自启](#四启动与开机自启)
- [五、验证连通性](#五验证连通性)
- [配置参数速查](#配置参数速查)
- [常见问题](#常见问题)
---
## 环境说明
| 项目 | 说明 |
| ---- | ---- |
| 操作系统 | Ubuntu / Debian 系(本文以 `apt` 包管理为例) |
| 软件 | `coturn`TurnServer |
| 默认监听端口 | `3478` (UDP/TCP) |
| 中继端口范围 | `49152` - `65535` (UDP) |
---
## 一、安装 coturn
```bash
sudo apt update
sudo apt install coturn -y
```
安装完成后,建议先备份默认配置文件:
```bash
sudo cp /etc/turnserver.conf /etc/turnserver.conf.bak
```
---
## 二、配置 TURN 服务
使用编辑器打开配置文件:
```bash
sudo vim /etc/turnserver.conf
```
将以下内容写入(或按需修改)配置文件:
```ini
# ===== 监听设置 =====
listening-ip=0.0.0.0
listening-port=3478
# ===== 公网 IP =====
# 云服务器此处必须填写公网 IP切勿填写内网 IP
external-ip=你的公网IP
# ===== 认证WebRTC 必须开启,否则客户端无法连接)=====
lt-cred-mech
user=testuser:testpass
# realm 可填写任意域名,但不能留空
realm=yourdomain.com
# ===== 中继端口范围TURN 转发媒体流量使用)=====
relay-port-range=49152-65535
# ===== 日志 =====
verbose
```
> ⚠️ **注意**
> - `external-ip` 必须填写服务器的**公网 IP**,而非内网 IP。
> - `realm` 不能为空,可填写任意域名。
> - `user` 为客户端连接凭证,请在生产环境中替换为强密码。
---
## 三、开放防火墙端口
```bash
# TURN 主服务端口
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp # WebRTC 可选但建议开启
# TURN 中继端口范围
sudo ufw allow 49152:65535/udp
```
---
## 四、启动与开机自启
```bash
# 重启服务并设为开机自启
sudo systemctl restart coturn
sudo systemctl enable --now coturn
# 查看运行状态(应为 active (running)
systemctl status coturn
```
---
## 五、验证连通性
使用 coturn 自带的客户端工具进行连通性测试:
```bash
turnutils_uclient -v -u testuser -w testpass 你的公网IP
```
若输出中包含成功分配中继地址allocation的日志说明 TURN 服务工作正常。
---
## 配置参数速查
| 参数 | 说明 | 默认值 |
| ---- | ---- | ------ |
| `listening-ip` | 监听的网卡 IP | - |
| `listening-port` | 监听端口 | `3478` |
| `external-ip` | 公网 IP云服务器必填 | - |
| `lt-cred-mech` | 启用长期凭证认证 | 关闭 |
| `user` | 连接账号 `用户名:密码` | - |
| `realm` | 认证域(必填) | - |
| `relay-port-range` | 中继端口范围 | - |
| `verbose` | 输出详细日志 | 关闭 |
---
## 常见问题
**Q客户端始终无法连接**
- 确认 `lt-cred-mech` 已开启,且客户端使用的 `user` / `realm` 与服务端一致。
- 确认云服务器的**安全组**与本地 `ufw` 均已放行对应端口。
- 确认 `external-ip` 填写的是公网 IP。
**Q中继流量不通**
- 检查 `49152:65535/udp` 端口范围是否在防火墙与云安全组中放行。
**Q如何修改默认端口**
- 修改 `listening-port` 并同步放行新端口,同时确保客户端信令配置指向新端口。

View File

@@ -391,10 +391,11 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
| 方法 | 路径 | 令牌 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `iceServers`(含 urls/username/credential`expiresAt``ttlSeconds` |
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `enabled``iceServers`(含 urls/username/credential`expiresAt``ttlSeconds` |
```json
{
"enabled": true,
"iceServers": [
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
"username": "1730000000:ab12cd34ef56",
@@ -406,6 +407,7 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
```
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
> 服务端未启用 TURN 时返回 200 + `{"enabled": false, "iceServers": []}`(不会返回 401客户端据此降级为仅 STUN。
#### 骚扰举报P2 风控)
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。

View File

@@ -210,11 +210,24 @@ public class ClientController {
/**
* 获取 TURN 短期凭证ICE servers。主控端与被控端在建立 PeerConnection 前调用。
*
* <p>TURN 未启用时返回 200 + 空 iceServersenabled=false而不是 401
* 「服务未开启」并非认证失败,返回 401 会触发各客户端的令牌刷新/强制登出逻辑
* Web/Flutter 会无意义轮换 refreshTokenAndroid 端 401 拦截器会清理凭据)。
*/
@GetMapping("/turn-credentials")
public Map<String, Object> turnCredentials(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
return turnCredentialService.issue(principal.principalType() + ":" + principal.principalId());
if (!turnCredentialService.isEnabled()) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("enabled", false);
body.put("iceServers", List.of());
return body;
}
Map<String, Object> body = new LinkedHashMap<>(
turnCredentialService.issue(principal.principalType() + ":" + principal.principalId()));
body.put("enabled", true);
return body;
}
// ==================== 骚扰举报P2 风控) ====================

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
import { useUserStore } from '@/stores/user';
/**
* 请求基地址。
@@ -39,10 +40,11 @@ request.interceptors.response.use(
(error) => {
const status = error.response?.status;
if (status === 401) {
localStorage.removeItem('admin_token');
// 清空 store 内存与 localStorage 中的令牌,避免路由守卫因内存 token 残留把登录页弹回首页
useUserStore().logout();
ElMessage.error('登录已失效,请重新登录');
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' });
if (router.currentRoute.value.name !== 'Login') {
router.replace({ name: 'Login' });
}
} else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败');

View File

@@ -13,23 +13,40 @@
/// 配对 → candidate-pair=0 → ICE FailedAndroid 真机/网页则正常)。
/// 改用 `turns:`TLS over TCP 5349模拟器能稳定拿到 relay 候选。
/// 同时保留 udp / tcp 明文项作为降级,以及 STUN 用于 srflx。
///
/// **Linux / Windows 桌面端崩溃修复free(): invalid pointer**
/// flutter_webrtc 桌面端的原生 `RTCConfiguration` 使用**定长数组**存放
/// ICE 服务器:`IceServer ice_servers[kMaxIceServerSize]`,其中
/// `kMaxIceServerSize = 8`(见 third_party/libwebrtc/include/rtc_types.h
/// 而 `FlutterWebRTCBase::CreateIceServers()` 直接按传入列表长度写入该数组,
/// **没有做任何越界检查**。一旦条目数 > 8就会越界写入相邻堆内存
/// 并向非法地址赋值 `std::string`,最终在释放时触发
/// `free(): invalid pointer` 并导致进程闪退。
///
/// 因此下表条目数**必须 ≤ [kMaxIceServerCount]8**。
/// 这里按「同一组凭据合并为一条」的标准写法(`urls` 传数组),
/// 在不丢失任何服务器地址的前提下把条目压缩到 6 条。
///
/// **桌面端 `urls` 数组的额外注意点**
/// 同一函数里解析 `urls` 列表时是 `ice_server.uri = ...` 逐个**覆盖**赋值,
/// 只有**最后一个** URL 会真正生效Android / iOS / Web 则会全部生效)。
/// 因此每个数组都把「最通用、最可能连通」的地址放在**最后一位**
/// TURN 组以明文 `turn:...:3478`UDP收尾内网组以 `turn:` 收尾。
const int kMaxIceServerCount = 8;
const List<Map<String, dynamic>> kIceServers = [
// 公共 STUN。
{'urls': 'stun:stun.l.google.com:19302'},
{'urls': 'stun:175.178.213.60:3478'},
{
// 首选TLS over TCPiOS 模拟器下能稳定分配 relay 候选
'urls': 'turns:175.178.213.60:5349',
'username': 'fanhuitong',
'credential': 'Fan19961907..',
},
{
// 降级:明文 UDP/TCP。
'urls': 'turn:175.178.213.60:3478',
'username': 'fanhuitong',
'credential': 'Fan19961907..',
},
{
'urls': 'turn:175.178.213.60:3478?transport=tcp',
// 同一组凭据的多个 TURN 地址合并为一条
// 顺序说明:移动端/Web 全部生效;桌面端只取最后一个,
// 故把兼容性最好的明文 UDP turn 放在末尾。
'urls': [
'turns:175.178.213.60:5349',
'turn:175.178.213.60:3478?transport=tcp',
'turn:175.178.213.60:3478',
],
'username': 'fanhuitong',
'credential': 'Fan19961907..',
},
@@ -39,9 +56,12 @@ const List<Map<String, dynamic>> kIceServers = [
'username': 'ttstd',
'credential': 'fanhuitong',
},
{'urls': 'stun:192.168.5.224:3478'},
{
'urls': 'turn:192.168.5.224:3478',
// 内网 STUN/TURN同机房/局域网时可直连)。
'urls': [
'stun:192.168.5.224:3478',
'turn:192.168.5.224:3478',
],
'username': 'tt',
'credential': 'fht',
},

View File

@@ -1,4 +1,6 @@
import 'package:fixnum/fixnum.dart';
import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
/// 控制指令构造工具,对应 Android 端 RemoteTouchView 的指令格式。
@@ -8,6 +10,63 @@ import 'package:webrtc_controller_flutter/core/proto/control_message.pb.dart';
class ControlCommands {
ControlCommands._();
/// 将 Flutter 的 [LogicalKeyboardKey] 转换为 Android 标准 [KeyEvent.keyCode]。
///
/// 说明Flutter 的 [LogicalKeyboardKey.keyId] 是 Unicode 抽象值(如字母 'w'
/// 的 keyId 为 0x77=119而 Android 端注入按键时使用的是 [KeyEvent.keyCode]
/// (如 KEYCODE_W=50。两者语义不同若直接把 Flutter 的 keyId 当作 Android
/// keyCode 发给被控端,'w' 会被误识别为 KEYCODE_F8(119) 等,导致触发系统组合键
/// /功能键(屏幕乱跳、输入框无字符输入)。因此这里统一转换成 Android keyCode。
static int toAndroidKeyCode(LogicalKeyboardKey key) {
// 字母 a-z / A-ZAndroid KEYCODE_A=29 起
const a = 0x61; // 'a'
const z = 0x7a; // 'z'
const au = 0x41; // 'A'
const zu = 0x5a; // 'Z'
final id = key.keyId;
if (id >= a && id <= z) return id - a + 29;
if (id >= au && id <= zu) return id - au + 29;
// 数字 0-9Android KEYCODE_0=7 起
if (id >= 0x30 && id <= 0x39) return id - 0x30 + 7;
// 其余按键用 LogicalKeyboardKey 常量精确匹配,避免魔法数字。
// 注意LogicalKeyboardKey 重写了 ==/hashCode不能作为 const map 的 key
// 因此使用普通(非 constMap 字面量。
const home = LogicalKeyboardKey.home;
const end = LogicalKeyboardKey.end;
final map = <LogicalKeyboardKey, int>{
LogicalKeyboardKey.space: 62,
LogicalKeyboardKey.enter: 66,
LogicalKeyboardKey.numpadEnter: 66,
LogicalKeyboardKey.backspace: 67,
LogicalKeyboardKey.tab: 61,
LogicalKeyboardKey.escape: 111,
LogicalKeyboardKey.delete: 112,
home: 122,
end: 123,
LogicalKeyboardKey.pageUp: 92,
LogicalKeyboardKey.pageDown: 93,
LogicalKeyboardKey.arrowLeft: 21,
LogicalKeyboardKey.arrowUp: 19,
LogicalKeyboardKey.arrowRight: 22,
LogicalKeyboardKey.arrowDown: 20,
// 功能键 F1-F12Android 131..142
LogicalKeyboardKey.f1: 131,
LogicalKeyboardKey.f2: 132,
LogicalKeyboardKey.f3: 133,
LogicalKeyboardKey.f4: 134,
LogicalKeyboardKey.f5: 135,
LogicalKeyboardKey.f6: 136,
LogicalKeyboardKey.f7: 137,
LogicalKeyboardKey.f8: 138,
LogicalKeyboardKey.f9: 139,
LogicalKeyboardKey.f10: 140,
LogicalKeyboardKey.f11: 141,
LogicalKeyboardKey.f12: 142,
};
return map[key] ?? 0;
}
/// 单击指令。坐标 x/y 为相对于屏幕的百分比0.0 ~ 1.0)。
static ControlMessage touch(double x, double y) => ControlMessage(
action: Action.TOUCH,

View File

@@ -4,6 +4,8 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webrtc_controller_flutter/core/utils/control_commands.dart';
/// 远端触摸控制层(对应 Android 端 RemoteTouchView
///
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
@@ -43,6 +45,10 @@ class RemoteTouchView extends StatefulWidget {
}
class _RemoteTouchViewState extends State<RemoteTouchView> {
/// 用于捕获物理键盘事件的焦点节点。
/// 设置 [FocusNode.skipTraversal] 避免桌面端获得焦点时绘制一圈绿色高亮边框。
final FocusNode _focusNode = FocusNode(skipTraversal: true);
Offset? _startPosition;
Offset? _currentPosition;
DateTime? _startTime;
@@ -77,19 +83,41 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
@override
void dispose() {
_cancelLongPress();
_focusNode.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
// 关闭框架的焦点高亮描边:本控件需要 autofocus 捕获物理键盘,但框架在
// 检测到键盘交互(传统高亮模式)后会给焦点节点绘制一圈绿色高亮边框。
// 将高亮策略设为 alwaysTouch 后边框不再绘制,键盘事件捕获不受影响。
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
}
@override
Widget build(BuildContext context) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
return Theme(
data: Theme.of(context).copyWith(
focusColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Focus(
autofocus: true,
focusNode: _focusNode,
onKeyEvent: (node, event) {
// 必须将 Flutter 的逻辑键转换为 Android keyCode 再下发,否则会被控端
// 误识别为功能键/组合键(屏幕乱跳、输入框无法输入文字)。
final int androidKeyCode = ControlCommands.toAndroidKeyCode(event.logicalKey);
if (androidKeyCode == 0) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
widget.onKey(event.logicalKey.keyId, 0);
widget.onKey(androidKeyCode, 0);
return KeyEventResult.handled;
} else if (event is KeyUpEvent) {
widget.onKey(event.logicalKey.keyId, 1);
widget.onKey(androidKeyCode, 1);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@@ -177,6 +205,7 @@ class _RemoteTouchViewState extends State<RemoteTouchView> {
child: SizedBox.expand(),
),
),
);
}
),
);
}
}

View File

@@ -6,6 +6,7 @@ import '../../../app/constants/app_constants.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/storage/token_storage.dart';
import '../domain/auth_repository.dart';
import '../../connection/domain/binding_device.dart';
/// 基于 Dio 的 [AuthRepository] 实现。
class DioAuthRepository implements AuthRepository {
@@ -101,13 +102,41 @@ class DioAuthRepository implements AuthRepository {
Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
@override
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
Future<List<BindingDevice>> getBindings() async {
final data = await _get('/api/client/bindings');
final list = (data['bindings'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<List<BindingDevice>> getOnlineDevices() async {
final data = await _get('/api/client/devices/online');
final list = (data['devices'] as List?) ?? [];
return [
for (final e in list)
if (e is Map)
BindingDevice.fromJson(e.cast<String, dynamic>()),
];
}
@override
Future<void> redeemPairingCode(String code) async {
await _post(
'/api/client/pairing/redeem',
body: {'code': code},
auth: true,
);
}
@override
Future<Map<String, dynamic>?> turnCredentials() async {
try {
return await _get('/api/client/turn-credentials');
} on ApiException {
} on Object {
return null;
}
}
@@ -121,24 +150,61 @@ class DioAuthRepository implements AuthRepository {
Future<Map<String, dynamic>> _post(
String path, {
required Map<String, dynamic> body,
bool auth = false,
}) async {
final res = await _dio.post<dynamic>(
path,
data: jsonEncode(body),
return _handle(
await _send(
() => _dio.post<dynamic>(
path,
data: jsonEncode(body),
options: Options(
headers: {
if (auth && _accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
);
return _handle(res);
}
Future<Map<String, dynamic>> _get(String path) async {
final res = await _dio.get<dynamic>(
path,
options: Options(
headers: {
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
},
return _handle(
await _send(
() => _dio.get<dynamic>(
path,
options: Options(
headers: {
if (_accessToken != null)
'Authorization': 'Bearer $_accessToken',
},
),
),
),
);
return _handle(res);
}
/// 发送请求,遇 401 时尝试用 refreshToken 续期后重试一次。
///
/// Dio 默认对非 2xx 直接抛出 [DioException]bad response不会走到
/// [_handle],因此在异常层拦截 401 并触发刷新重试,避免已登录用户因
/// accessToken 过期而直接失败。
Future<Response<dynamic>> _send(
Future<Response<dynamic>> Function() request,
) async {
try {
return await request();
} on DioException catch (e) {
if (e.response?.statusCode == 401 && await hasRefreshToken()) {
try {
await refresh();
} on Object {
rethrow;
}
return await request();
}
rethrow;
}
}
Map<String, dynamic> _handle(Response<dynamic> res) {

View File

@@ -1,3 +1,5 @@
import '../../connection/domain/binding_device.dart';
/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。
abstract interface class AuthRepository {
/// 内存中的 accessToken掉线即失
@@ -19,7 +21,14 @@ abstract interface class AuthRepository {
Future<Map<String, dynamic>> verify();
Future<Map<String, dynamic>> bindings();
/// 获取主控端已绑定的被控设备列表(含名称、鉴权方式等)。
Future<List<BindingDevice>> getBindings();
/// 获取已绑定且当前在线(信令网络活跃)的设备列表。
Future<List<BindingDevice>> getOnlineDevices();
/// 使用被控端展示的一次性配对码建立绑定关系。
Future<void> redeemPairingCode(String code);
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers关闭时返回 null。
Future<Map<String, dynamic>?> turnCredentials();

View File

@@ -14,20 +14,31 @@ import 'webrtc_controller.dart';
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
class RemoteController {
final String serverUrl;
final String targetDeviceId;
final AuthRepository authRepository;
String? token;
/// 目标被控端设备ID在发起远程控制时指定。
String? targetDeviceId;
String? authType;
String? authValue;
late final SignalingClient _signaling;
SignalingClient? _signaling;
WebRtcController? _webRtc;
Timer? _statsTimer;
/// 本地设备ID由服务端 REGISTER_SUCCESS 下发)。
String? _myDeviceId;
/// 信令是否已注册成功(拿到本机 deviceId
bool get registered => _myDeviceId != null;
/// 本机设备ID未注册时为 null
String? get myDeviceId => _myDeviceId;
/// 是否已发起远程控制WebRTC 会话存在)。
bool get controlling => _webRtc != null;
/// 信令状态变化(如"正在连接…"、"已连接…)。
void Function(String status)? onStatusChanged;
@@ -80,20 +91,23 @@ class RemoteController {
/// 强制下线4003用于跳回登录。
void Function()? onForceLogout;
/// 信令注册成功(下发本机 deviceId此时可发起远程控制。
void Function(String myDeviceId)? onRegistered;
RemoteController({
required this.serverUrl,
required this.targetDeviceId,
required this.authRepository,
this.targetDeviceId,
this.token,
this.authType,
this.authValue,
});
/// 发起连接:确保 accessToken,成功后携带 Bearer 建立 WebSocket
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 再建立 WebRTC 并创建 Offer
Future<void> connect({String? authType, String? authValue}) async {
this.authType = authType;
this.authValue = authValue;
/// 仅建立信令连接:确保 accessToken 后携带 Bearer 建立 WebSocket
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 即完成,**不会**发起远程控制
///
/// 发起远程控制请在注册成功后调用 [startControl]。
Future<void> connectSignaling() async {
onStatusChanged?.call('状态: 正在连接信令服务器...');
try {
@@ -104,20 +118,22 @@ class RemoteController {
return;
}
_signaling = SignalingClient(serverUrl: serverUrl, token: token);
_signaling.onConnected = () {
final signaling = SignalingClient(serverUrl: serverUrl, token: token);
_signaling = signaling;
signaling.onConnected = () {
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
};
_signaling.onMessage = _handleSignalMessage;
_signaling.onDisconnected = () {
signaling.onMessage = _handleSignalMessage;
signaling.onDisconnected = () {
_myDeviceId = null;
onStatusChanged?.call('状态: 已断开连接');
onDisconnected?.call();
};
_signaling.onError = (error) {
signaling.onError = (error) {
onStatusChanged?.call('状态: 连接错误 - $error');
onConnectionFailed?.call(error);
};
_signaling.onTokenExpired = () async {
signaling.onTokenExpired = () async {
try {
await authRepository.refresh();
token = authRepository.accessToken;
@@ -127,11 +143,38 @@ class RemoteController {
onTokenExpired?.call();
}
};
_signaling.onForceLogout = () {
signaling.onForceLogout = () {
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
onForceLogout?.call();
};
_signaling.connect();
signaling.connect();
}
/// 发起远程控制:在信令注册成功后调用,建立 WebRTC 并向目标设备创建 Offer。
///
/// [target] 目标被控端设备ID[authType]/[authValue] 为被控端鉴权方式。
void startControl({
required String target,
String? authType,
String? authValue,
}) {
if (!registered) {
onConnectionFailed?.call('信令未就绪,请稍后重试');
return;
}
if (_webRtc != null) return;
targetDeviceId = target;
this.authType = authType;
this.authValue = authValue;
_initWebRtc();
}
/// 停止远程控制但保持信令连接(便于重新选择设备发起连接)。
Future<void> stopControl() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
}
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
@@ -150,20 +193,22 @@ class RemoteController {
}
void _reconnect() {
_signaling.disconnect();
_signaling.connect();
_signaling?.disconnect();
_signaling?.connect();
}
void _initWebRtc() {
final deviceId = _myDeviceId;
if (deviceId == null) {
final signaling = _signaling;
final target = targetDeviceId;
if (deviceId == null || signaling == null || target == null) {
onStatusChanged?.call('状态: 未获取到本机设备ID连接中止');
return;
}
_webRtc = WebRtcController(
signaling: _signaling,
signaling: signaling,
deviceId: deviceId,
targetDeviceId: targetDeviceId,
targetDeviceId: target,
authType: authType,
authValue: authValue,
);
@@ -195,10 +240,10 @@ class RemoteController {
final type = message.type?.toUpperCase();
if (type == 'REGISTER_SUCCESS') {
// 服务端下发本机 deviceId作为后续 OFFER 的 fromDeviceId。
_myDeviceId = message.fromDeviceId;
_initWebRtc();
// 拉取可连接的被控端绑定列表(仅已绑定设备)。
_loadBindings();
final deviceId = message.fromDeviceId;
_myDeviceId = deviceId;
onStatusChanged?.call('状态: 已连接信令服务器 ($deviceId)');
if (deviceId != null) onRegistered?.call(deviceId);
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
_loadTurnCredentials();
return;
@@ -239,27 +284,6 @@ class RemoteController {
return payload;
}
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。
Future<void> _loadBindings() async {
try {
final data = await authRepository.bindings();
final list = (data['bindings'] as List?) ?? [];
if (list.isNotEmpty) {
final uids = list.map((e) {
if (e is Map) {
return (e['deviceUid'] ?? e['deviceId'] ?? '').toString();
}
return e.toString();
}).where((s) => s.isNotEmpty).join(', ');
if (uids.isNotEmpty) {
onStatusChanged?.call('已绑定设备: $uids');
}
}
} catch (_) {
// 绑定列表拉取失败不影响主流程。
}
}
/// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。
Future<void> _loadTurnCredentials() async {
final data = await authRepository.turnCredentials();
@@ -297,12 +321,11 @@ class RemoteController {
Future<void> sendStreamMode(int mode) =>
_webRtc?.sendStreamMode(mode) ?? Future.value();
/// 断开连接并释放资源。
/// 断开连接并释放资源(同时关闭 WebRTC 与信令)
Future<void> disconnect() async {
_statsTimer?.cancel();
_statsTimer = null;
await _webRtc?.close();
_webRtc = null;
_signaling.disconnect();
await stopControl();
_signaling?.disconnect();
_signaling = null;
_myDeviceId = null;
}
}

View File

@@ -103,18 +103,23 @@ class WebRtcController {
Future<void> initialize() async {
await renderer.initialize();
final iceServers = iceServersOverride ?? kIceServers;
// 桌面端Linux/Windows原生 RTCConfiguration 的 ice_servers 是定长数组
// kMaxIceServerSize = 8且原生解析未做越界检查条目超限会越界写堆内存
// 触发 `free(): invalid pointer` 直接闪退。这里做一次防御性截断兜底。
final safeIceServers = _limitIceServers(kIceServers);
final configuration = {
'iceServers': iceServers,
'iceServers': safeIceServers,
'sdpSemantics': 'unified-plan',
'iceCandidatePoolSize': 10,
// 增强复杂网络下的稳定性,参考 Android 端配置。
'continualGatheringPolicy': 'gatherContinually',
'iceTransportsType': 'all',
// 标准键名为 iceTransportPolicy桌面端原生只识别这个键
'iceTransportPolicy': 'all',
'tcpCandidatePolicy': 'enabled',
};
final iceUrls = iceServers
final iceUrls = safeIceServers
.map((e) => '${e['username'] != null ? '(${e['username']})' : ''}${e['urls']}')
.join(', ');
debugPrint('[WebRtcController] signaling server: ${signaling.serverUrl} | '
@@ -174,6 +179,25 @@ class WebRtcController {
_sendOffer(reorderedSdp, authType: authType, authValue: authValue);
}
/// 防御性限制 ICE 服务器条目数,避免桌面端原生越界写内存导致闪退。
///
/// flutter_webrtc 桌面端 `FlutterWebRTCBase::CreateIceServers()` 会按传入
/// 列表长度直接写入定长数组 `IceServer ice_servers[8]`**无越界检查**。
/// 条目数 > 8 时会破坏相邻堆内存,在释放阶段抛出
/// `free(): invalid pointer` 并使进程崩溃Android/iOS 用动态列表不受影响)。
///
/// 正常情况下 [kIceServers] 已控制在 8 条以内,此处仅作为后续新增服务器
/// 时的安全网:超限时截断并打印告警,而不是让应用直接闪退。
List<Map<String, dynamic>> _limitIceServers(
List<Map<String, dynamic>> servers,
) {
if (servers.length <= kMaxIceServerCount) return servers;
debugPrint('[WebRtcController] 警告: ICE 服务器数量 ${servers.length} '
'超过原生上限 $kMaxIceServerCount,已截断以避免桌面端崩溃。'
'请在 lib/config/ice_servers.dart 中合并同凭据的地址。');
return servers.sublist(0, kMaxIceServerCount);
}
/// 重排 Offer SDP 中 `m=video` 行的视频编解码器顺序VP8/VP9 优先H264 兜底。
///
/// 与 Android 被控端 [WebRtcClient.optimizeSdp] 的编解码优先级保持一致,
@@ -501,7 +525,7 @@ class WebRtcController {
/// 通过 DataChannel 发送控制指令protobuf 二进制)。
void sendControlCommand(ControlMessage command) {
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
debugPrint('WebRtcController: Sending command -> ${command.action}');
debugPrint('WebRtcController: Sending command -> ${command.action} keyCode ${command.keyCode}');
_dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
}
}

View File

@@ -0,0 +1,63 @@
import 'package:json_annotation/json_annotation.dart';
part 'binding_device.g.dart';
/// 主控端已绑定的被控设备(对应服务端 DeviceBinding / 在线设备列表)。
///
/// 字段对齐 [WebRTCSignalServer] 的 [DeviceBinding] 与 OnDeviceDto
/// 以及 Android 控制端的 [BindingItem],供用户在列表中选取并连接。
@JsonSerializable(fieldRename: FieldRename.snake)
class BindingDevice {
const BindingDevice({
required this.deviceUid,
this.name,
this.online = false,
this.authType = 'NONE',
this.lastSeen,
});
/// 被控端设备唯一标识(连接信令时使用)。
///
/// 服务端 [DeviceBinding.deviceUid](必返回,缺失时兜底空串以保证列表可用)。
@JsonKey(name: 'deviceUid')
final String deviceUid;
/// 设备展示名称,对应服务端 [DeviceBinding.alias],可为 null。
///
/// 未设置别名时回退为设备标识,避免空名称影响展示与选择。
@JsonKey(name: 'alias')
final String? name;
/// 是否在线(信令网络中活跃)。
final bool online;
/// 连接鉴权方式NONE / PASSWORD / CODE。
///
/// 服务端字段为 [DeviceBinding.authType],缺失时默认 NONE。
@JsonKey(name: 'authType', defaultValue: 'NONE')
final String authType;
/// 最近一次在线时间戳(毫秒)。
@JsonKey(name: 'lastSeen')
final int? lastSeen;
factory BindingDevice.fromJson(Map<String, dynamic> json) =>
_$BindingDeviceFromJson(json);
Map<String, dynamic> toJson() => _$BindingDeviceToJson(this);
BindingDevice copyWith({
String? deviceUid,
String? name,
bool? online,
String? authType,
int? lastSeen,
}) =>
BindingDevice(
deviceUid: deviceUid ?? this.deviceUid,
name: name ?? this.name,
online: online ?? this.online,
authType: authType ?? this.authType,
lastSeen: lastSeen ?? this.lastSeen,
);
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'binding_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BindingDevice _$BindingDeviceFromJson(Map<String, dynamic> json) =>
BindingDevice(
deviceUid: json['deviceUid'] as String? ?? '',
name: json['alias'] as String? ?? json['deviceUid'] as String? ?? '',
online: json['online'] as bool? ?? false,
authType: json['authType'] as String? ?? 'NONE',
lastSeen: json['lastSeen'] as int?,
);
Map<String, dynamic> _$BindingDeviceToJson(BindingDevice instance) =>
<String, dynamic>{
'deviceUid': instance.deviceUid,
'alias': instance.name,
'online': instance.online,
'authType': instance.authType,
'lastSeen': instance.lastSeen,
};

View File

@@ -2,6 +2,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../app/constants/app_constants.dart';
import 'binding_device.dart';
part 'connection_session_state.freezed.dart';
@@ -15,9 +16,36 @@ class ConnectionSessionState with _$ConnectionSessionState {
/// 是否正在连接信令服务器 / 建立 WebRTC。
@Default(false) bool connecting,
/// 信令WebSocket是否已连接并注册成功。
@Default(false) bool signalingConnected,
/// 信令注册后服务端下发的本机设备ID。
String? myDeviceId,
/// 状态提示文本。
@Default('') String status,
/// 主控端已绑定的被控设备列表(供用户选择连接)。
@Default(<BindingDevice>[]) List<BindingDevice> bindings,
/// 当前在线的已绑定设备 uid 集合(用于列表标记在线状态)。
@Default(<String>{}) Set<String> onlineUids,
/// 用户在绑定设备列表中选中的目标设备 uid。
String? selectedDeviceUid,
/// 是否正在拉取绑定设备列表 / 在线状态。
@Default(false) bool deviceRefreshing,
/// 设备列表加载失败时的错误提示null 表示无错误)。
String? deviceError,
/// 是否正在兑换配对码建立绑定。
@Default(false) bool redeeming,
/// 配对码兑换失败提示null 表示无错误)。
String? redeemError,
/// 连接统计文本(每秒刷新)。
@Default('') String stats,
@@ -68,5 +96,5 @@ class ConnectionSessionState with _$ConnectionSessionState {
/// 一次性提示消息(消费后由控制器置空)。
String? alert,
}) = _ConnectionSessionState;
}) = _$ConnectionSessionStateImpl;
}

View File

@@ -8,6 +8,7 @@ import '../../auth/data/auth_providers.dart';
import '../data/remote_controller.dart';
import '../data/self_codec_decoder.dart';
import '../data/video_recorder.dart';
import '../domain/binding_device.dart';
import '../domain/connection_session_state.dart';
part 'connection_controller.g.dart';
@@ -40,32 +41,159 @@ class ConnectionController extends _$ConnectionController {
void _clearAlert() => state = state.copyWith(alert: null);
/// 发起连接:先确保已登录,携带 Bearer 建立信令,成功后建立 WebRTC
Future<void> connect({
required String serverUrl,
required String targetDeviceId,
required String authType,
required String authValue,
}) async {
if (serverUrl.isEmpty || targetDeviceId.isEmpty) {
_alert('请填写服务器地址和目标设备ID');
/// 仅建立信令WebSocket连接**不**发起远程控制
///
/// 进入设置页即可调用;重复调用会被忽略。
Future<void> connectSignaling({required String serverUrl}) async {
if (serverUrl.isEmpty) {
_alert('信令服务器地址为空');
return;
}
if (_remote != null || state.signalingConnected) return;
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(connecting: true, status: '状态: 正在连接信令服务器...');
_remote = RemoteController(
serverUrl: serverUrl,
targetDeviceId: targetDeviceId,
authRepository: authRepository,
);
_wireRemoteCallbacks();
await _remote!.connect(authType: authType, authValue: authValue);
await _remote!.connectSignaling();
}
/// 发起远程控制:需信令已就绪,向目标设备创建 Offer。
Future<void> startControl({
required String targetDeviceId,
required String authType,
required String authValue,
}) async {
if (targetDeviceId.isEmpty) {
_alert('请填写目标设备ID');
return;
}
if (!state.signalingConnected || _remote == null) {
_alert('信令未就绪,请稍后重试');
return;
}
state = state.copyWith(connecting: true, status: '状态: 正在发起连接...');
_remote!.startControl(
target: targetDeviceId,
authType: authType,
authValue: authValue,
);
}
/// 拉取已绑定设备列表,并合并在线设备状态。
///
/// 参考 Android 控制端的 [MainViewModel.loadBindings] / [getOnlineDevices]
/// 用在线列表标记绑定设备是否可立即连接。
Future<void> loadDevices() async {
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(deviceRefreshing: true, deviceError: null);
try {
final bindings = await authRepository.getBindings();
List<BindingDevice> online = const [];
try {
online = await authRepository.getOnlineDevices();
} on Object {
// 在线列表接口失败时,仍展示全部绑定(仅不标记在线)。
}
final onlineUids = {for (final d in online) d.deviceUid};
final merged = [
for (final b in bindings)
b.copyWith(online: onlineUids.contains(b.deviceUid)),
];
final stillSelected = merged
.where((b) => b.deviceUid == state.selectedDeviceUid)
.isNotEmpty;
state = state.copyWith(
bindings: merged,
onlineUids: onlineUids,
deviceRefreshing: false,
selectedDeviceUid: stillSelected ? state.selectedDeviceUid : null,
);
} on Object catch (e) {
debugPrint(e.toString());
state = state.copyWith(
deviceRefreshing: false,
deviceError: '加载绑定设备失败:$e',
);
}
}
/// 在绑定设备列表中选择一个目标设备(仅记录选择,不下发连接)。
void selectDevice(String uid) {
if (state.selectedDeviceUid == uid) return;
state = state.copyWith(selectedDeviceUid: uid);
}
/// 当前选中的绑定设备(无则返回 null
BindingDevice? get selectedDevice {
final uid = state.selectedDeviceUid;
if (uid == null) return null;
for (final b in state.bindings) {
if (b.deviceUid == uid) return b;
}
return null;
}
/// 向选中的绑定设备发起远程控制。
///
/// 鉴权方式取自 [BindingDevice.authType],凭据由 UI 的鉴权对话框收集后传入。
Future<void> connectSelectedDevice({
required String authType,
required String authValue,
}) async {
final target = selectedDevice;
if (target == null) {
_alert('请先选择一个绑定的设备');
return;
}
if (!target.online) {
_alert('设备「${target.name}」当前不在线,无法连接');
return;
}
await startControl(
targetDeviceId: target.deviceUid,
authType: authType,
authValue: authValue,
);
}
/// 使用被控端展示的配对码建立绑定关系,并刷新绑定设备列表。
Future<bool> redeemPairingCode(String code) async {
final trimmed = code.trim();
if (trimmed.isEmpty) {
state = state.copyWith(redeemError: '请输入配对码');
return false;
}
final authRepository = ref.read(authRepositoryProvider);
state = state.copyWith(redeeming: true, redeemError: null);
try {
await authRepository.redeemPairingCode(trimmed);
state = state.copyWith(redeeming: false);
await loadDevices();
return true;
} on Object catch (e) {
state = state.copyWith(redeeming: false, redeemError: '绑定失败:$e');
return false;
}
}
/// 清除配对码兑换错误提示。
void clearRedeemError() => state.copyWith(redeemError: null);
void _wireRemoteCallbacks() {
final remote = _remote!;
remote.onStatusChanged = _setStatus;
remote.onRegistered = (myDeviceId) {
state = state.copyWith(
signalingConnected: true,
connecting: false,
myDeviceId: myDeviceId,
);
};
remote.onConnectionEstablished = () {
state = state.copyWith(
connected: true,
@@ -78,20 +206,26 @@ class ConnectionController extends _$ConnectionController {
_alert('连接失败:$error');
};
remote.onDisconnected = () {
state = state.copyWith(connected: false, connecting: false);
state = state.copyWith(
connected: false,
connecting: false,
signalingConnected: false,
myDeviceId: null,
);
_setStatus('状态: 远端已断开');
};
// 以下三种情况仅结束远程控制,保留信令连接以便重新发起。
remote.onIceDisconnected = (message) {
_alert(message);
disconnect();
stopControl();
};
remote.onTargetOffline = (message) {
_alert(message);
state = state.copyWith(connected: false, connecting: false);
stopControl();
};
remote.onConnectionRejected = (message) {
_alert(message);
state = state.copyWith(connected: false, connecting: false);
stopControl();
};
remote.onRemoteStream = (renderer) {
_renderer = renderer;
@@ -114,7 +248,8 @@ class ConnectionController extends _$ConnectionController {
};
remote.onStreamModeReport = (mode) {
state = state.copyWith(streamMode: mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && state.pendingRecordStart) {
if (mode == SelfCodecDecoder.streamModeWebRtc &&
state.pendingRecordStart) {
state = state.copyWith(pendingRecordStart: false);
_startRecording();
}
@@ -261,7 +396,23 @@ class ConnectionController extends _$ConnectionController {
_remote?.sendControlCommand(command);
}
/// 断开连接并复位全部 UI 状态
/// 结束远程控制但保持信令连接,便于重新选择设备发起连接
Future<void> stopControl() async {
await _videoRecorder.dispose();
await _remote?.stopControl();
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
final signalingAlive = state.signalingConnected;
state = ConnectionSessionState(
status: signalingAlive ? '状态: 已连接信令服务器' : '状态: 已停止',
signalingConnected: signalingAlive,
myDeviceId: state.myDeviceId,
alert: state.alert,
);
}
/// 断开全部连接(含信令)并复位 UI 状态。
Future<void> disconnect() async {
await _videoRecorder.dispose();
await _remote?.disconnect();
@@ -269,7 +420,7 @@ class ConnectionController extends _$ConnectionController {
_renderer?.removeListener(_onRendererUpdate);
_renderer = null;
_lastResizedAspect = 0;
state = const ConnectionSessionState(status: '状态: 已停止');
state = ConnectionSessionState(status: '状态: 已停止', alert: state.alert);
}
/// 消费一次性提示消息UI 展示后调用)。

View File

@@ -7,7 +7,7 @@ part of 'connection_controller.dart';
// **************************************************************************
String _$connectionControllerHash() =>
r'bea162831e84bb80f7921d7c774d76e07969b023';
r'83ae35ab7dbb3a7d23106106040e9146f1dc6782';
/// 连接控制会话控制器:编排信令 + WebRTC管理控制端全部 UI 状态。
///

View File

@@ -266,7 +266,8 @@ class _ControlPageState extends ConsumerState<ControlPage> {
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: () async {
await ref.read(connectionControllerProvider.notifier).disconnect();
// 仅结束远程控制,保留信令连接以便返回设置页后直接重连。
await ref.read(connectionControllerProvider.notifier).stopControl();
if (mounted) context.go('/');
},
child: const Icon(

View File

@@ -6,11 +6,12 @@ import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
import '../../../../app/constants/app_constants.dart';
import '../../../auth/presentation/auth_controller.dart';
import '../../../auth/presentation/widgets/login_dialog.dart';
import '../../domain/binding_device.dart';
import '../../domain/connection_session_state.dart';
import '../connection_controller.dart';
import '../widgets/auth_dialog.dart';
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
/// 连接设置页:服务器地址 / 绑定设备选择 / 登录 / 连接。
class SetupPage extends ConsumerStatefulWidget {
const SetupPage({super.key});
@@ -19,32 +20,73 @@ class SetupPage extends ConsumerStatefulWidget {
}
class _SetupPageState extends ConsumerState<SetupPage> {
final _serverUrlController = TextEditingController(
text: kDefaultSignalServer,
);
final _deviceIdController = TextEditingController();
final _targetController = TextEditingController(text: '981964879');
@override
void initState() {
super.initState();
// build() 期间不能改动 provider 状态,推迟到首帧之后。
WidgetsBinding.instance.addPostFrameCallback((_) => _autoConnectSignaling());
}
@override
void dispose() {
_serverUrlController.dispose();
_deviceIdController.dispose();
_targetController.dispose();
super.dispose();
}
/// 进入页面后自动建立信令WebSocket连接并拉取绑定设备列表。
///
/// 仅连接信令并完成注册,**不会**向任何设备发起远程控制;
/// 能进入本页即已登录,携带 Bearer token 即可建连。
Future<void> _autoConnectSignaling() async {
if (!mounted) return;
final loggedIn =
ref.read(authControllerProvider).valueOrNull?.loggedIn ?? false;
if (!loggedIn) return;
await ref
.read(connectionControllerProvider.notifier)
.connectSignaling(serverUrl: kDefaultSignalServer);
if (!mounted) return;
await ref.read(connectionControllerProvider.notifier).loadDevices();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final authState = ref.watch(authControllerProvider).valueOrNull;
final connection = ref.watch(connectionControllerProvider);
// 连接建立后跳转控制页
// 登录完成后补发信令连接;退出登录则断开信令
ref.listen(authControllerProvider, (prev, next) {
final was = prev?.valueOrNull?.loggedIn ?? false;
final now = next.valueOrNull?.loggedIn ?? false;
if (!was && now) {
_autoConnectSignaling();
} else if (was && !now) {
ref.read(connectionControllerProvider.notifier).disconnect();
}
});
// 信令注册成功后回显服务端下发的本机设备ID。
final myDeviceId = connection.myDeviceId ?? '';
if (_deviceIdController.text != myDeviceId) {
_deviceIdController.text = myDeviceId;
}
// 连接建立后跳转控制页;连接失败等一次性提示展示后立即消费。
ref.listen<ConnectionSessionState>(
connectionControllerProvider,
(prev, next) {
if ((prev?.connected ?? false) == false && next.connected) {
context.go('/control');
return;
}
final alert = next.alert;
if (alert != null && alert.isNotEmpty) {
ref.read(connectionControllerProvider.notifier).consumeAlert();
_showAlert(alert);
}
},
);
@@ -59,25 +101,6 @@ class _SetupPageState extends ConsumerState<SetupPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.appTitle,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _serverUrlController,
placeholder: l10n.serverUrlPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
@@ -89,18 +112,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
horizontal: 12,
),
),
const SizedBox(height: 16),
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 8),
CupertinoTextField(
controller: _targetController,
placeholder: l10n.targetDeviceIdPlaceholder,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 12,
),
),
const SizedBox(height: 24),
_buildDeviceSection(context, l10n, connection),
const SizedBox(height: 16),
Text(
connection.status,
style: const TextStyle(
@@ -108,6 +122,13 @@ class _SetupPageState extends ConsumerState<SetupPage> {
fontWeight: FontWeight.bold,
),
),
if (connection.deviceError != null) ...[
const SizedBox(height: 8),
Text(
connection.deviceError!,
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
@@ -129,7 +150,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
}
},
child: Text(
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
(authState?.loggedIn ?? false)
? l10n.logout
: l10n.loginAccount,
),
),
),
@@ -150,12 +173,142 @@ class _SetupPageState extends ConsumerState<SetupPage> {
);
}
/// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。
Widget _buildDeviceSection(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.boundDevices, style: const TextStyle(fontSize: 16)),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.deviceRefreshing
? null
: () => ref
.read(connectionControllerProvider.notifier)
.loadDevices(),
child: connection.deviceRefreshing
? const CupertinoActivityIndicator(radius: 9)
: Text(l10n.refreshDevices),
),
],
),
const SizedBox(height: 8),
if (connection.bindings.isEmpty)
_buildEmptyHint(context, l10n, connection)
else
_DeviceList(
devices: connection.bindings,
selectedUid: connection.selectedDeviceUid,
onSelect: (uid) => ref
.read(connectionControllerProvider.notifier)
.selectDevice(uid),
),
const SizedBox(height: 12),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: connection.redeeming
? null
: () => _showRedeemDialog(context, l10n),
child: connection.redeeming
? const CupertinoActivityIndicator()
: Text(l10n.redeemPairingCode),
),
],
);
}
/// 无绑定设备时的引导提示(含配对失败提示)。
Widget _buildEmptyHint(
BuildContext context,
AppLocalizations l10n,
ConnectionSessionState connection,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.noBoundDevices,
style: const TextStyle(fontSize: 14, color: CupertinoColors.systemGrey),
),
if (connection.redeemError != null) ...[
const SizedBox(height: 8),
Text(
connection.redeemError!,
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
),
],
],
);
}
/// 兑换配对码对话框。
Future<void> _showRedeemDialog(
BuildContext context,
AppLocalizations l10n,
) async {
final controller = TextEditingController();
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text(l10n.redeemPairingCode),
content: Column(
children: [
const SizedBox(height: 12),
CupertinoTextField(
controller: controller,
placeholder: l10n.pairingCodeHint,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(ctx).pop(false),
),
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(ctx).pop(true),
),
],
),
);
if (ok != true || !mounted) {
controller.dispose();
return;
}
final code = controller.text.trim();
// 延迟到下一帧再释放,避免对话框卸载时焦点回调访问已释放的 controller。
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (code.isEmpty) return;
final success = await ref
.read(connectionControllerProvider.notifier)
.redeemPairingCode(code);
if (success && mounted) {
_showAlert(l10n.bindSuccess);
} else if (mounted) {
final err = ref.read(connectionControllerProvider).redeemError;
if (err != null && err.isNotEmpty) _showAlert(err);
}
}
Future<void> _onConnectPressed() async {
final l10n = AppLocalizations.of(context);
final serverUrl = _serverUrlController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || target.isEmpty) {
_showAlert(l10n.serverAndTargetRequired);
final selected =
ref.read(connectionControllerProvider.notifier).selectedDevice;
if (selected == null) {
_showAlert(l10n.pleaseSelectDevice);
return;
}
if (!selected.online) {
_showAlert(l10n.deviceOfflineCannotConnect(selected.name ?? selected.deviceUid));
return;
}
// 连接前确保已登录Bearer token
@@ -169,12 +322,16 @@ class _SetupPageState extends ConsumerState<SetupPage> {
if (ok != true || !mounted) return;
}
// 信令若尚未就绪(如刚登录或曾断开),先补建连接。
if (!ref.read(connectionControllerProvider).signalingConnected) {
await _autoConnectSignaling();
if (!mounted) return;
}
final selection = await showAuthDialog(context);
if (selection == null || !mounted) return;
await ref.read(connectionControllerProvider.notifier).connect(
serverUrl: serverUrl,
targetDeviceId: target,
await ref.read(connectionControllerProvider.notifier).connectSelectedDevice(
authType: selection.type,
authValue: selection.value,
);
@@ -195,3 +352,38 @@ class _SetupPageState extends ConsumerState<SetupPage> {
);
}
}
/// 绑定设备选择列表Cupertino 风格)。
class _DeviceList extends StatelessWidget {
const _DeviceList({
required this.devices,
required this.selectedUid,
required this.onSelect,
});
final List<BindingDevice> devices;
final String? selectedUid;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CupertinoListSection.insetGrouped(
margin: EdgeInsets.zero,
children: [
for (final d in devices)
CupertinoListTile(
title: Text(d.name ?? d.deviceUid),
subtitle: Text(d.online ? l10n.online : l10n.offline),
trailing: selectedUid == d.deviceUid
? const Icon(
CupertinoIcons.check_mark_circled_solid,
color: CupertinoColors.activeBlue,
)
: null,
onTap: () => onSelect(d.deviceUid),
),
],
);
}
}

View File

@@ -13,8 +13,7 @@
"connecting": "Connecting...",
"loginAccount": "Login Account",
"connectDevice": "Connect Device",
"serverUrl": "Signal Server URL:",
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
"deviceId": "Device ID (assigned by server):",
"deviceIdPlaceholder": "Assigned by server",
"targetDeviceId": "Target Device ID:",
@@ -23,7 +22,7 @@
"password": "Password",
"loginFailed": "Login failed: {error}",
"usernamePasswordRequired": "Please enter username and password",
"serverAndTargetRequired": "Please enter server URL and target device ID",
"targetRequired": "Please enter target device ID",
"authTitle": "Connection Auth",
"authNone": "Passwordless",
"authNoneDesc": "Manual confirmation on the controlled device",
@@ -67,5 +66,16 @@
"forceLogout": "Account logged in elsewhere, force logged out.",
"loginFirst": "Please login before connecting",
"pleaseFillAuth": "Please enter the code or password",
"streamModeSelfCodecDesc": "Self-codec hardware decoding is not supported on this platform."
"streamModeSelfCodecDesc": "Self-codec hardware decoding is not supported on this platform.",
"boundDevices": "Bound Devices",
"online": "Online",
"offline": "Offline",
"noBoundDevices": "No bound devices yet",
"refreshDevices": "Refresh",
"redeemPairingCode": "Bind with Pairing Code",
"pairingCodeHint": "Enter the pairing code shown on the controlled device",
"bindingFailed": "Binding failed: {error}",
"deviceOfflineCannotConnect": "Device \"{name}\" is currently offline and cannot be connected",
"pleaseSelectDevice": "Please select a bound device first",
"bindSuccess": "Bound successfully"
}

View File

@@ -176,18 +176,6 @@ abstract class AppLocalizations {
/// **'连接被控设备'**
String get connectDevice;
/// No description provided for @serverUrl.
///
/// In zh, this message translates to:
/// **'信令服务器地址:'**
String get serverUrl;
/// No description provided for @serverUrlPlaceholder.
///
/// In zh, this message translates to:
/// **'wss://www.ttstd.com/signal'**
String get serverUrlPlaceholder;
/// No description provided for @deviceId.
///
/// In zh, this message translates to:
@@ -236,11 +224,11 @@ abstract class AppLocalizations {
/// **'请输入用户名和密码'**
String get usernamePasswordRequired;
/// No description provided for @serverAndTargetRequired.
/// No description provided for @targetRequired.
///
/// In zh, this message translates to:
/// **'请填写服务器地址和目标设备ID'**
String get serverAndTargetRequired;
/// **'请填写目标设备ID'**
String get targetRequired;
/// No description provided for @authTitle.
///
@@ -505,6 +493,72 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'当前平台不支持自编码硬解。'**
String get streamModeSelfCodecDesc;
/// No description provided for @boundDevices.
///
/// In zh, this message translates to:
/// **'已绑定设备'**
String get boundDevices;
/// No description provided for @online.
///
/// In zh, this message translates to:
/// **'在线'**
String get online;
/// No description provided for @offline.
///
/// In zh, this message translates to:
/// **'离线'**
String get offline;
/// No description provided for @noBoundDevices.
///
/// In zh, this message translates to:
/// **'暂无已绑定设备'**
String get noBoundDevices;
/// No description provided for @refreshDevices.
///
/// In zh, this message translates to:
/// **'刷新'**
String get refreshDevices;
/// No description provided for @redeemPairingCode.
///
/// In zh, this message translates to:
/// **'使用配对码绑定'**
String get redeemPairingCode;
/// No description provided for @pairingCodeHint.
///
/// In zh, this message translates to:
/// **'请输入被控端显示的配对码'**
String get pairingCodeHint;
/// No description provided for @bindingFailed.
///
/// In zh, this message translates to:
/// **'绑定失败:{error}'**
String bindingFailed(Object error);
/// No description provided for @deviceOfflineCannotConnect.
///
/// In zh, this message translates to:
/// **'设备「{name}」当前不在线,无法连接'**
String deviceOfflineCannotConnect(Object name);
/// No description provided for @pleaseSelectDevice.
///
/// In zh, this message translates to:
/// **'请先选择一个绑定的设备'**
String get pleaseSelectDevice;
/// No description provided for @bindSuccess.
///
/// In zh, this message translates to:
/// **'绑定成功'**
String get bindSuccess;
}
class _AppLocalizationsDelegate

View File

@@ -49,12 +49,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get connectDevice => 'Connect Device';
@override
String get serverUrl => 'Signal Server URL:';
@override
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
@override
String get deviceId => 'Device ID (assigned by server):';
@@ -82,8 +76,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get usernamePasswordRequired => 'Please enter username and password';
@override
String get serverAndTargetRequired =>
'Please enter server URL and target device ID';
String get targetRequired => 'Please enter target device ID';
@override
String get authTitle => 'Connection Auth';
@@ -239,4 +232,42 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get streamModeSelfCodecDesc =>
'Self-codec hardware decoding is not supported on this platform.';
@override
String get boundDevices => 'Bound Devices';
@override
String get online => 'Online';
@override
String get offline => 'Offline';
@override
String get noBoundDevices => 'No bound devices yet';
@override
String get refreshDevices => 'Refresh';
@override
String get redeemPairingCode => 'Bind with Pairing Code';
@override
String get pairingCodeHint =>
'Enter the pairing code shown on the controlled device';
@override
String bindingFailed(Object error) {
return 'Binding failed: $error';
}
@override
String deviceOfflineCannotConnect(Object name) {
return 'Device \"$name\" is currently offline and cannot be connected';
}
@override
String get pleaseSelectDevice => 'Please select a bound device first';
@override
String get bindSuccess => 'Bound successfully';
}

View File

@@ -49,12 +49,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get connectDevice => '连接被控设备';
@override
String get serverUrl => '信令服务器地址:';
@override
String get serverUrlPlaceholder => 'wss://www.ttstd.com/signal';
@override
String get deviceId => '本机设备ID连接后由服务端下发无需填写:';
@@ -82,7 +76,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get usernamePasswordRequired => '请输入用户名和密码';
@override
String get serverAndTargetRequired => '请填写服务器地址和目标设备ID';
String get targetRequired => '请填写目标设备ID';
@override
String get authTitle => '连接鉴权';
@@ -231,4 +225,41 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get streamModeSelfCodecDesc => '当前平台不支持自编码硬解。';
@override
String get boundDevices => '已绑定设备';
@override
String get online => '在线';
@override
String get offline => '离线';
@override
String get noBoundDevices => '暂无已绑定设备';
@override
String get refreshDevices => '刷新';
@override
String get redeemPairingCode => '使用配对码绑定';
@override
String get pairingCodeHint => '请输入被控端显示的配对码';
@override
String bindingFailed(Object error) {
return '绑定失败:$error';
}
@override
String deviceOfflineCannotConnect(Object name) {
return '设备「$name」当前不在线,无法连接';
}
@override
String get pleaseSelectDevice => '请先选择一个绑定的设备';
@override
String get bindSuccess => '绑定成功';
}

View File

@@ -13,8 +13,7 @@
"connecting": "正在连接...",
"loginAccount": "登录账号",
"connectDevice": "连接被控设备",
"serverUrl": "信令服务器地址:",
"serverUrlPlaceholder": "wss://www.ttstd.com/signal",
"deviceId": "本机设备ID连接后由服务端下发无需填写:",
"deviceIdPlaceholder": "连接后由服务端下发",
"targetDeviceId": "目标被控设备ID:",
@@ -23,7 +22,7 @@
"password": "密码",
"loginFailed": "登录失败:{error}",
"usernamePasswordRequired": "请输入用户名和密码",
"serverAndTargetRequired": "请填写服务器地址和目标设备ID",
"targetRequired": "请填写目标设备ID",
"authTitle": "连接鉴权",
"authNone": "免密连接",
"authNoneDesc": "被控端弹出手动确认框,无需验证码或密码",
@@ -67,5 +66,16 @@
"forceLogout": "账号已在其他位置登录,已强制下线。",
"loginFirst": "请先登录后再连接",
"pleaseFillAuth": "请输入验证码或密码",
"streamModeSelfCodecDesc": "当前平台不支持自编码硬解。"
"streamModeSelfCodecDesc": "当前平台不支持自编码硬解。",
"boundDevices": "已绑定设备",
"online": "在线",
"offline": "离线",
"noBoundDevices": "暂无已绑定设备",
"refreshDevices": "刷新",
"redeemPairingCode": "使用配对码绑定",
"pairingCodeHint": "请输入被控端显示的配对码",
"bindingFailed": "绑定失败:{error}",
"deviceOfflineCannotConnect": "设备「{name}」当前不在线,无法连接",
"pleaseSelectDevice": "请先选择一个绑定的设备",
"bindSuccess": "绑定成功"
}

View File

@@ -4,6 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 全局关闭键盘焦点高亮边框
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
runApp(
const ProviderScope(
child: WebrtcControllerApp(),

View File

@@ -21,11 +21,14 @@
FB000000000000000000000C /* AuthSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000C /* AuthSheetView.swift */; };
FB000000000000000000000D /* ControllerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000D /* ControllerViewModel.swift */; };
FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; };
FB0000000000000000000015 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000015 /* ApiClient.swift */; };
FB0000000000000000000016 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000016 /* TokenStore.swift */; };
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; };
FB0000000000000000000013 /* SetupPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000013 /* SetupPanelView.swift */; };
FB0000000000000000000014 /* ControlPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000014 /* ControlPanelView.swift */; };
FB0000000000000000000017 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000017 /* LoginView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -43,12 +46,15 @@
FA000000000000000000000C /* AuthSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSheetView.swift; sourceTree = "<group>"; };
FA000000000000000000000D /* ControllerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerViewModel.swift; sourceTree = "<group>"; };
FA000000000000000000000E /* DeviceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtils.swift; sourceTree = "<group>"; };
FA0000000000000000000015 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
FA0000000000000000000016 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStore.swift; sourceTree = "<group>"; };
FA000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
FA0000000000000000000012 /* VideoRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRecorder.swift; sourceTree = "<group>"; };
FA0000000000000000000013 /* SetupPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPanelView.swift; sourceTree = "<group>"; };
FA0000000000000000000014 /* ControlPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanelView.swift; sourceTree = "<group>"; };
FA0000000000000000000017 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -141,6 +147,7 @@
FA0000000000000000000008 /* RemoteTouchView.swift */,
FA0000000000000000000009 /* RemoteVideoView.swift */,
FA000000000000000000000A /* SelfCodecDisplayView.swift */,
FA0000000000000000000017 /* LoginView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -157,6 +164,8 @@
isa = PBXGroup;
children = (
FA000000000000000000000E /* DeviceUtils.swift */,
FA0000000000000000000015 /* ApiClient.swift */,
FA0000000000000000000016 /* TokenStore.swift */,
);
path = Utils;
sourceTree = "<group>";
@@ -250,10 +259,13 @@
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
FB0000000000000000000017 /* LoginView.swift in Sources */,
FB000000000000000000000B /* ContentView.swift in Sources */,
FB000000000000000000000C /* AuthSheetView.swift in Sources */,
FB000000000000000000000D /* ControllerViewModel.swift in Sources */,
FB000000000000000000000E /* DeviceUtils.swift in Sources */,
FB0000000000000000000015 /* ApiClient.swift in Sources */,
FB0000000000000000000016 /* TokenStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -45,12 +45,32 @@ struct ApiClient {
return try await get("/api/client/turn-credentials", accessToken: accessToken)
}
/// 线线
/// Flutter `devices_online`GET /api/client/devices/online
static func onlineDevices(accessToken: String) async throws -> [String: Any] {
return try await get("/api/client/devices/online", accessToken: accessToken)
}
///
/// Flutter `redeemPairingCode`POST /api/client/pairing/redeem
static func redeemPairingCode(accessToken: String, code: String) async throws -> [String: Any] {
let body: [String: Any] = ["code": code]
return try await post("/api/client/pairing/redeem", body: body, accessToken: accessToken)
}
// MARK: - Private
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
return try await post(path, body: body, accessToken: nil)
}
private static func post(_ path: String, body: [String: Any], accessToken: String?) async throws -> [String: Any] {
var req = URLRequest(url: URL(string: baseURL + path)!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let accessToken, !accessToken.isEmpty {
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req)
}

View File

@@ -42,7 +42,7 @@ enum TokenStore {
static func clear() {
for k in [accessKey, refreshKey, userKey] {
let query: [String: Any] = [
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: k
@@ -55,7 +55,7 @@ enum TokenStore {
private static func save(key: String, value: String) {
//
let query: [String: Any] = [
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
@@ -66,7 +66,7 @@ enum TokenStore {
}
private static func load(key: String) -> String? {
let query: [String: Any] = [
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,

View File

@@ -41,6 +41,14 @@ struct ResolutionOption: Identifiable, Equatable {
]
}
/// 线 Flutter BindingDevice.fromOnlineJson
struct OnlineDevice: Identifiable, Equatable {
let deviceId: String
let nickname: String
var id: String { deviceId }
}
/// ViewModelWebRTC UI
/// 线@Published SwiftUI
final class ControllerViewModel: NSObject, ObservableObject {
@@ -67,6 +75,17 @@ final class ControllerViewModel: NSObject, ObservableObject {
@Published var showAuthSheet: Bool = false
@Published var alertMessage: String?
// MARK: -
/// 线GET /api/client/devices/online
@Published var onlineDevices: [OnlineDevice] = []
/// 线
@Published var isLoadingOnlineDevices: Bool = false
///
@Published var pairingCode: String = ""
///
@Published var isRedeemingPairing: Bool = false
///
@Published var isRecording: Bool = false
///
@@ -156,10 +175,12 @@ final class ControllerViewModel: NSObject, ObservableObject {
alertMessage = "请先在右上角登录账号"
return
}
loadOnlineDevices()
showAuthSheet = true
}
/// accessToken
/// accessToken Flutter connectSignaling verify/refresh
/// 4001
func connect(authType: AuthType, authValue: String) {
showAuthSheet = false
pendingAuthType = authType
@@ -167,12 +188,25 @@ final class ControllerViewModel: NSObject, ObservableObject {
isConnecting = true
statusText = "正在连接信令服务器..."
let signaling = SignalingClient(
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces),
token: accessToken)
signaling.delegate = self
signalingClient = signaling
signaling.connect()
Task {
do {
let token = try await ensureAccessToken()
accessToken = token
await MainActor.run { [weak self] in
guard let self else { return }
let signaling = SignalingClient(
serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces),
token: token)
signaling.delegate = self
self.signalingClient = signaling
signaling.connect()
}
} catch {
await MainActor.run { [weak self] in
self?.failConnection((error as? ApiError)?.message ?? "令牌失效,请重新登录")
}
}
}
}
func disconnect() {
@@ -391,7 +425,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
do {
let newAt = try await ensureAccessToken()
accessToken = newAt
DispatchQueue.main.async {
await MainActor.run { [weak self] in
guard let self else { return }
self.signalingClient?.disconnect()
self.signalingClient = nil
// REGISTER_SUCCESS startWebRTC
@@ -403,7 +438,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
signaling.connect()
}
} catch {
DispatchQueue.main.async {
await MainActor.run { [weak self] in
guard let self else { return }
self.logout()
self.alertMessage = "令牌刷新失败,请重新登录"
}
@@ -417,19 +453,81 @@ final class ControllerViewModel: NSObject, ObservableObject {
do {
let data = try await ApiClient.bindings(accessToken: at)
guard let list = data["bindings"] as? [[String: Any]], !list.isEmpty else { return }
let uids = list.compactMap { $ -> String? in
if let u = $["deviceUid"] as? String, !u.isEmpty { return u }
return $["deviceId"] as? String
let uids = list.compactMap { entry -> String? in
if let u = entry["deviceUid"] as? String, !u.isEmpty { return u }
return entry["deviceId"] as? String
}.filter { !$0.isEmpty }
if !uids.isEmpty {
DispatchQueue.main.async {
self.alertMessage = "已绑定设备: " + uids.joined(separator: ", ")
let message = "已绑定设备: " + uids.joined(separator: ", ")
Task { @MainActor [weak self] in
self?.alertMessage = message
}
}
} catch { /* */ }
}
}
/// 线GET /api/client/devices/online线
/// Flutter RemoteController.loadOnlineDevices / BindingDevice.fromOnlineJson
func loadOnlineDevices() {
guard let at = accessToken else { return }
isLoadingOnlineDevices = true
Task {
do {
let data = try await ApiClient.onlineDevices(accessToken: at)
let list = (data["devices"] as? [[String: Any]]) ?? []
let devices: [OnlineDevice] = list.compactMap { entry in
guard let id = entry["deviceId"] as? String, !id.isEmpty else { return nil }
let nickname = (entry["nickname"] as? String) ?? id
return OnlineDevice(deviceId: id, nickname: nickname)
}
await MainActor.run { [weak self] in
guard let self else { return }
self.onlineDevices = devices
self.isLoadingOnlineDevices = false
}
} catch {
await MainActor.run { [weak self] in
self?.isLoadingOnlineDevices = false
}
}
}
}
/// POST /api/client/pairing/redeem
/// Flutter RemoteController.redeemPairingCode
func redeemPairingCode() {
let code = pairingCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else {
alertMessage = "请输入配对码"
return
}
guard let at = accessToken else {
alertMessage = "请先登录账号"
return
}
isRedeemingPairing = true
Task {
do {
let data = try await ApiClient.redeemPairingCode(accessToken: at, code: code)
let deviceId = (data["deviceId"] as? String) ?? ""
let nickname = (data["nickname"] as? String) ?? deviceId
await MainActor.run { [weak self] in
guard let self else { return }
self.isRedeemingPairing = false
self.pairingCode = ""
self.alertMessage = "绑定成功:\(nickname)\(deviceId)"
self.loadOnlineDevices()
}
} catch {
await MainActor.run { [weak self] in
self?.isRedeemingPairing = false
self?.alertMessage = "配对失败:\((error as? ApiError)?.message ?? error.localizedDescription)"
}
}
}
}
/// TURN ICE WebRTCClient
private func loadTurnCredentials() async {
guard let at = accessToken else { return }
@@ -470,7 +568,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
webRTCClient?.stats { [weak self] report in
guard let self else { return }
let text = self.buildStatsText(report)
DispatchQueue.main.async {
Task { @MainActor in
self.statsText = text
}
}

View File

@@ -26,6 +26,63 @@ struct SetupPanelView: View {
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
.autocapitalization(.allCharacters)
.disableAutocorrection(true)
Button {
viewModel.loadOnlineDevices()
} label: {
HStack {
Text("刷新在线设备")
Spacer()
if viewModel.isLoadingOnlineDevices {
ProgressView()
}
}
}
}
if !viewModel.onlineDevices.isEmpty {
Section("在线设备(点击填入目标 ID") {
ForEach(viewModel.onlineDevices) { device in
Button {
viewModel.targetDeviceId = device.deviceId
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(device.nickname)
Text(device.deviceId)
.font(.system(.caption, design: .monospaced))
.foregroundColor(.secondary)
}
Spacer()
if viewModel.targetDeviceId == device.deviceId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
}
Section("配对码兑换(建立新绑定)") {
HStack {
TextField("输入被控端显示的配对码", text: $viewModel.pairingCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
viewModel.redeemPairingCode()
} label: {
if viewModel.isRedeemingPairing {
ProgressView()
} else {
Text("兑换")
}
}
.disabled(viewModel.isRedeemingPairing || viewModel.pairingCode.trimmingCharacters(in: .whitespaces).isEmpty)
}
Text("在被控端生成配对码后填入此处即可建立绑定,绑定成功后会出现在上方在线设备列表中。")
.font(.caption)
.foregroundColor(.secondary)
}
Section {