feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
158
webrtc_controller_flutter/lib/api/api_client.dart
Normal file
158
webrtc_controller_flutter/lib/api/api_client.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。
|
||||
const String kApiBase = String.fromEnvironment(
|
||||
'API_BASE',
|
||||
defaultValue: 'https://www.ttstd.com',
|
||||
);
|
||||
|
||||
/// 账号体系与自助接口封装(对应服务端 /api/auth/* 与 /api/client/*)。
|
||||
///
|
||||
/// 令牌存储策略:
|
||||
/// - accessToken:内存保存(掉线即失,需重新登录);
|
||||
/// - refreshToken:flutter_secure_storage(Keychain / EncryptedSharedPreferences,一次性,ses_ 前缀)。
|
||||
class ApiClient {
|
||||
ApiClient({http.Client? client}) : _http = client ?? http.Client();
|
||||
|
||||
final http.Client _http;
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
static const _kAccess = 'ttstd.accessToken';
|
||||
static const _kRefresh = 'ttstd.refreshToken';
|
||||
static const _kUsername = 'ttstd.username';
|
||||
|
||||
// 刷新单飞:避免并发触发多次刷新。
|
||||
Future<Map<String, dynamic>>? _refreshing;
|
||||
|
||||
String? _accessToken;
|
||||
String? get accessToken => _accessToken;
|
||||
|
||||
/// 从安全存储恢复令牌(应用启动时调用)。
|
||||
Future<void> restore() async {
|
||||
_accessToken = await _storage.read(key: _kAccess);
|
||||
}
|
||||
|
||||
bool get hasTokens {
|
||||
// 同时读取内存 accessToken 与持久 refreshToken 判断。
|
||||
return _accessToken != null;
|
||||
}
|
||||
|
||||
Future<bool> hasRefreshToken() async =>
|
||||
(await _storage.read(key: _kRefresh)) != null;
|
||||
|
||||
Future<void> saveTokens({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
String? username,
|
||||
}) async {
|
||||
_accessToken = accessToken;
|
||||
await _storage.write(key: _kAccess, value: accessToken);
|
||||
if (refreshToken != null) {
|
||||
await _storage.write(key: _kRefresh, value: refreshToken);
|
||||
}
|
||||
if (username != null) {
|
||||
await _storage.write(key: _kUsername, value: username);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getUsername() => _storage.read(key: _kUsername);
|
||||
|
||||
Future<void> clear() async {
|
||||
_accessToken = null;
|
||||
await _storage.delete(key: _kAccess);
|
||||
await _storage.delete(key: _kRefresh);
|
||||
await _storage.delete(key: _kUsername);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> login(String username, String password) async {
|
||||
final data = await _post('/api/auth/login',
|
||||
body: {'username': username, 'password': password});
|
||||
await saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String?,
|
||||
username: username,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> register(String username, String password) =>
|
||||
_post('/api/auth/register',
|
||||
body: {'username': username, 'password': password});
|
||||
|
||||
/// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。
|
||||
Future<Map<String, dynamic>> refresh() async {
|
||||
final rt = await _storage.read(key: _kRefresh);
|
||||
if (rt == null) {
|
||||
await clear();
|
||||
throw ApiException('NO_REFRESH_TOKEN', 401);
|
||||
}
|
||||
_refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null);
|
||||
return _refreshing!;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _doRefresh(String rt) async {
|
||||
final data = await _post('/api/auth/refresh', body: {'refreshToken': rt});
|
||||
await saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String?,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||||
|
||||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回空 Map。
|
||||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||||
try {
|
||||
return await _get('/api/client/turn-credentials');
|
||||
} on ApiException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, {required Map<String, dynamic> body}) async {
|
||||
final res = await _http.post(
|
||||
Uri.parse('$kApiBase$path'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(String path) async {
|
||||
final res = await _http.get(
|
||||
Uri.parse('$kApiBase$path'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handle(http.Response res) {
|
||||
final body = res.body.isNotEmpty ? jsonDecode(res.body) : <String, dynamic>{};
|
||||
if (res.statusCode == 401) {
|
||||
throw ApiException(body['code'] ?? 'UNAUTHORIZED', 401);
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw ApiException(body['error'] ?? 'HTTP ${res.statusCode}', res.statusCode);
|
||||
}
|
||||
return body as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
/// API 调用异常,携带服务端 code 与 HTTP 状态码。
|
||||
class ApiException implements Exception {
|
||||
final String code;
|
||||
final int httpCode;
|
||||
ApiException(this.code, this.httpCode);
|
||||
|
||||
@override
|
||||
String toString() => code;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
import '../models/signal_message.dart';
|
||||
import '../proto/control_message.pb.dart';
|
||||
import '../signaling/signaling_client.dart';
|
||||
@@ -12,8 +13,10 @@ import '../webrtc/webrtc_controller.dart';
|
||||
/// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。
|
||||
class RemoteController {
|
||||
final String serverUrl;
|
||||
final String deviceId;
|
||||
final String targetDeviceId;
|
||||
final ApiClient apiClient;
|
||||
String? token;
|
||||
|
||||
String? authType;
|
||||
String? authValue;
|
||||
|
||||
@@ -21,7 +24,10 @@ class RemoteController {
|
||||
WebRtcController? _webRtc;
|
||||
Timer? _statsTimer;
|
||||
|
||||
/// 信令状态变化(如“正在连接…”、“已连接…”)。
|
||||
/// 本地设备ID(由服务端 REGISTER_SUCCESS 下发)。
|
||||
String? _myDeviceId;
|
||||
|
||||
/// 信令状态变化(如"正在连接…"、"已连接…)。
|
||||
void Function(String status)? onStatusChanged;
|
||||
|
||||
/// WebRTC 连接建立(可开始远程控制)。
|
||||
@@ -67,24 +73,39 @@ class RemoteController {
|
||||
/// 当前平台不支持原生硬解时回调。
|
||||
void Function()? onSelfCodecNotSupported;
|
||||
|
||||
/// 令牌失效(4001):用于触发刷新重连。
|
||||
void Function()? onTokenExpired;
|
||||
|
||||
/// 强制下线(4003):用于跳回登录。
|
||||
void Function()? onForceLogout;
|
||||
|
||||
RemoteController({
|
||||
required this.serverUrl,
|
||||
required this.deviceId,
|
||||
required this.targetDeviceId,
|
||||
required this.apiClient,
|
||||
this.token,
|
||||
this.authType,
|
||||
this.authValue,
|
||||
});
|
||||
|
||||
/// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。
|
||||
void connect({String? authType, String? authValue}) {
|
||||
/// 发起连接:先确保 accessToken,成功后携带 Bearer 建立 WebSocket,
|
||||
/// 待 REGISTER_SUCCESS 拿到本机 deviceId 再建立 WebRTC 并创建 Offer。
|
||||
Future<void> connect({String? authType, String? authValue}) async {
|
||||
this.authType = authType;
|
||||
this.authValue = authValue;
|
||||
onStatusChanged?.call('状态: 正在连接信令服务器...');
|
||||
|
||||
_signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId);
|
||||
try {
|
||||
token = await _ensureToken();
|
||||
} catch (e) {
|
||||
onStatusChanged?.call('状态: 认证失败 - $e');
|
||||
onConnectionFailed?.call(e.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
_signaling = SignalingClient(serverUrl: serverUrl, token: token);
|
||||
_signaling.onConnected = () {
|
||||
onStatusChanged?.call('状态: 已连接信令服务器,正在发起连接...');
|
||||
_initWebRtc();
|
||||
onStatusChanged?.call('状态: 已连接信令服务器,等待注册...');
|
||||
};
|
||||
_signaling.onMessage = _handleSignalMessage;
|
||||
_signaling.onDisconnected = () {
|
||||
@@ -95,10 +116,49 @@ class RemoteController {
|
||||
onStatusChanged?.call('状态: 连接错误 - $error');
|
||||
onConnectionFailed?.call(error);
|
||||
};
|
||||
_signaling.onTokenExpired = () async {
|
||||
try {
|
||||
await apiClient.refresh();
|
||||
token = apiClient.accessToken;
|
||||
_reconnect();
|
||||
} catch (e) {
|
||||
onStatusChanged?.call('状态: 令牌刷新失败 - $e');
|
||||
onTokenExpired?.call();
|
||||
}
|
||||
};
|
||||
_signaling.onForceLogout = () {
|
||||
onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线');
|
||||
onForceLogout?.call();
|
||||
};
|
||||
_signaling.connect();
|
||||
}
|
||||
|
||||
/// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。
|
||||
Future<String> _ensureToken() async {
|
||||
final existing = apiClient.accessToken;
|
||||
if (existing != null) {
|
||||
try {
|
||||
await apiClient.verify();
|
||||
return existing;
|
||||
} on ApiException catch (e) {
|
||||
if (e.httpCode != 401) return existing;
|
||||
}
|
||||
}
|
||||
final data = await apiClient.refresh();
|
||||
return data['accessToken'] as String;
|
||||
}
|
||||
|
||||
void _reconnect() {
|
||||
_signaling.disconnect();
|
||||
_signaling.connect();
|
||||
}
|
||||
|
||||
void _initWebRtc() {
|
||||
final deviceId = _myDeviceId;
|
||||
if (deviceId == null) {
|
||||
onStatusChanged?.call('状态: 未获取到本机设备ID,连接中止');
|
||||
return;
|
||||
}
|
||||
_webRtc = WebRtcController(
|
||||
signaling: _signaling,
|
||||
deviceId: deviceId,
|
||||
@@ -123,6 +183,7 @@ class RemoteController {
|
||||
_webRtc!.onFpsReport =
|
||||
(w, h, fps, list) => onFpsReport?.call(w, h, fps, list);
|
||||
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
|
||||
onStatusChanged?.call('状态: 已注册 ($deviceId),正在发起连接...');
|
||||
_webRtc!.initialize().catchError((e) {
|
||||
onStatusChanged?.call('状态: 连接失败 - $e');
|
||||
onConnectionFailed?.call(e.toString());
|
||||
@@ -130,10 +191,20 @@ class RemoteController {
|
||||
}
|
||||
|
||||
void _handleSignalMessage(SignalMessage message) {
|
||||
switch (message.type?.toUpperCase()) {
|
||||
final type = message.type?.toUpperCase();
|
||||
if (type == 'REGISTER_SUCCESS') {
|
||||
// 服务端下发本机 deviceId,作为后续 OFFER 的 fromDeviceId。
|
||||
_myDeviceId = message.fromDeviceId;
|
||||
_initWebRtc();
|
||||
// 拉取可连接的被控端绑定列表(仅已绑定设备)。
|
||||
_loadBindings();
|
||||
// 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。
|
||||
_loadTurnCredentials();
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case 'ANSWER':
|
||||
final payload = jsonDecode(message.payload!) as Map<String, dynamic>;
|
||||
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
|
||||
onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...');
|
||||
_webRtc?.handleAnswer(payload['sdp'] as String);
|
||||
break;
|
||||
@@ -167,6 +238,40 @@ class RemoteController {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。
|
||||
Future<void> _loadBindings() async {
|
||||
try {
|
||||
final data = await apiClient.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 apiClient.turnCredentials();
|
||||
if (data != null && data['iceServers'] is List) {
|
||||
final servers = (data['iceServers'] as List)
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
if (servers.isNotEmpty) {
|
||||
WebRtcController.iceServersOverride = servers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startStats() {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import 'api/api_client.dart';
|
||||
import 'config/ice_servers.dart';
|
||||
import 'controller/remote_controller.dart';
|
||||
import 'utils/control_commands.dart';
|
||||
import 'utils/device_utils.dart';
|
||||
import 'webrtc/self_codec_decoder.dart';
|
||||
import 'webrtc/video_recorder.dart';
|
||||
import 'widgets/remote_touch_view.dart';
|
||||
@@ -48,6 +48,11 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
/// 账号 API 客户端(登录 / 刷新 / 绑定列表 / TURN)。
|
||||
final ApiClient _apiClient = ApiClient();
|
||||
|
||||
RemoteController? _controller;
|
||||
RTCVideoRenderer? _renderer;
|
||||
@@ -56,6 +61,9 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
/// 是否正在连接信令服务器 / 建立 WebRTC(连接过程中禁用“连接”按钮)。
|
||||
bool _connecting = false;
|
||||
String _status = '状态: 已停止';
|
||||
|
||||
/// 是否已登录(accessToken 存在)。
|
||||
bool _loggedIn = false;
|
||||
String _stats = '';
|
||||
double _videoAspect = 16 / 9;
|
||||
|
||||
@@ -95,15 +103,14 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDeviceId();
|
||||
}
|
||||
|
||||
/// 异步获取设备标识并填入设备ID输入框(非系统签名,使用兜底方案)。
|
||||
Future<void> _initDeviceId() async {
|
||||
final id = await DeviceUtils.getSerialNumber();
|
||||
if (mounted) {
|
||||
setState(() => _deviceIdController.text = id);
|
||||
}
|
||||
// 启动时从安全存储恢复令牌;设备ID 由服务端注册后下发,无需本地生成。
|
||||
_apiClient.restore().then((_) async {
|
||||
if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) {
|
||||
setState(() {
|
||||
_loggedIn = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -137,12 +144,14 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
|
||||
Future<void> _showAuthDialog() async {
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final deviceId = _deviceIdController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
|
||||
_setStatus('请填写所有字段');
|
||||
if (serverUrl.isEmpty || target.isEmpty) {
|
||||
_setStatus('请填写服务器地址和目标设备ID');
|
||||
return;
|
||||
}
|
||||
// 连接前确保已登录(Bearer token)。
|
||||
final authed = await _ensureLoggedIn();
|
||||
if (!authed) return;
|
||||
|
||||
String selectedType = 'NONE';
|
||||
final valueController = TextEditingController();
|
||||
@@ -279,11 +288,10 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
|
||||
Future<void> _connect({required String authType, required String authValue}) async {
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final deviceId = _deviceIdController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
|
||||
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
|
||||
_setStatus('请填写所有字段');
|
||||
if (serverUrl.isEmpty || target.isEmpty) {
|
||||
_setStatus('请填写服务器地址和目标设备ID');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,8 +300,9 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
|
||||
_controller = RemoteController(
|
||||
serverUrl: serverUrl,
|
||||
deviceId: deviceId,
|
||||
targetDeviceId: target,
|
||||
apiClient: _apiClient,
|
||||
token: _apiClient.accessToken,
|
||||
authType: authType,
|
||||
authValue: authValue,
|
||||
);
|
||||
@@ -378,10 +387,99 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
if (mounted) setState(() => _selfCodecSupported = false);
|
||||
_showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
|
||||
};
|
||||
_controller!.onTokenExpired = () {
|
||||
_showAlert('登录已失效,请重新登录后再连接。');
|
||||
_resetLogin();
|
||||
};
|
||||
_controller!.onForceLogout = () {
|
||||
_showAlert('账号已在其他位置登录,已强制下线。');
|
||||
_resetLogin();
|
||||
};
|
||||
|
||||
_controller!.connect(authType: authType, authValue: authValue);
|
||||
}
|
||||
|
||||
/// 确保已登录:若已有 refreshToken 则直接返回;否则弹出登录对话框,登录成功后返回。 */
|
||||
Future<bool> _ensureLoggedIn() async {
|
||||
if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) {
|
||||
return true;
|
||||
}
|
||||
final login = await _showLoginDialog();
|
||||
return login;
|
||||
}
|
||||
|
||||
/// 显示登录对话框:输入用户名/密码,调用 /api/auth/login 保存令牌。
|
||||
Future<bool> _showLoginDialog() async {
|
||||
_usernameController.clear();
|
||||
_passwordController.clear();
|
||||
final result = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: const Text('登录'),
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
CupertinoTextField(
|
||||
controller: _usernameController,
|
||||
placeholder: '用户名',
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
CupertinoTextField(
|
||||
controller: _passwordController,
|
||||
placeholder: '密码',
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('取消'),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
child: const Text('登录'),
|
||||
onPressed: () async {
|
||||
final user = _usernameController.text.trim();
|
||||
final pass = _passwordController.text.trim();
|
||||
if (user.isEmpty || pass.isEmpty) {
|
||||
_showAlert('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _apiClient.login(user, pass);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loggedIn = true;
|
||||
});
|
||||
}
|
||||
Navigator.of(ctx).pop(true);
|
||||
} catch (e) {
|
||||
_showAlert('登录失败:$e');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// 退出登录并复位 UI 状态(清空令牌、停止连接)。
|
||||
Future<void> _resetLogin() async {
|
||||
await _apiClient.clear();
|
||||
await _disconnect();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loggedIn = false;
|
||||
_deviceIdController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onRendererUpdate() {
|
||||
final w = _renderer?.value.width ?? 0;
|
||||
final h = _renderer?.value.height ?? 0;
|
||||
@@ -636,11 +734,12 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('本机设备ID:', style: TextStyle(fontSize: 14)),
|
||||
const Text('本机设备ID(连接后由服务端下发,无需填写):', style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _deviceIdController,
|
||||
placeholder: '设备ID',
|
||||
placeholder: '连接后由服务端下发',
|
||||
enabled: false,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -656,6 +755,21 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_status,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton.filled(
|
||||
onPressed: _loggedIn
|
||||
? () async {
|
||||
await _resetLogin();
|
||||
}
|
||||
: () async {
|
||||
final ok = await _showLoginDialog();
|
||||
if (!ok) _setStatus('请先登录后再连接');
|
||||
},
|
||||
child: Text(_loggedIn ? '退出登录' : '登录账号'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
@@ -6,13 +7,18 @@ import '../models/signal_message.dart';
|
||||
|
||||
/// 信令客户端,封装 WebSocket 连接与消息收发。
|
||||
///
|
||||
/// 连接成功后自动向服务器发送 REGISTER 注册为 CONTROLLER 设备,
|
||||
/// 与 Android 端 WebSocketClient 行为一致。
|
||||
/// 鉴权方式:浏览器/移动端原生 WebSocket 均可在握手阶段通过子协议
|
||||
/// (Sec-WebSocket-Protocol)传递 Bearer token:
|
||||
/// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.`<token>`'])
|
||||
/// 服务端从首子协议取出 auth.`<token>` 进行校验。
|
||||
///
|
||||
/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
|
||||
class SignalingClient {
|
||||
final String serverUrl;
|
||||
final String deviceId;
|
||||
final String? token;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
/// 收到信令消息回调(已解析为 SignalMessage)。
|
||||
void Function(SignalMessage message)? onMessage;
|
||||
@@ -26,23 +32,33 @@ class SignalingClient {
|
||||
/// 连接错误回调。
|
||||
void Function(String error)? onError;
|
||||
|
||||
SignalingClient({required this.serverUrl, required this.deviceId});
|
||||
/// 令牌失效(关闭码 4001):调用方应刷新令牌后重连。
|
||||
void Function()? onTokenExpired;
|
||||
|
||||
/// 建立 WebSocket 连接并注册设备。
|
||||
/// 强制下线(关闭码 4003):调用方应停止重连并跳登录。
|
||||
void Function()? onForceLogout;
|
||||
|
||||
SignalingClient({required this.serverUrl, this.token});
|
||||
|
||||
/// 建立 WebSocket 连接(携带 Bearer 子协议,不再发送 REGISTER)。
|
||||
void connect() {
|
||||
try {
|
||||
final uri = Uri.parse(serverUrl);
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
final protocols = ['signal.v1'];
|
||||
if (token != null && token!.isNotEmpty) {
|
||||
protocols.add('auth.$token');
|
||||
}
|
||||
_channel = WebSocketChannel.connect(uri, protocols: protocols);
|
||||
|
||||
_channel!.stream.listen(
|
||||
_onData,
|
||||
onDone: () => onDisconnected?.call(),
|
||||
onDone: _onDone,
|
||||
onError: (Object e) => onError?.call(e.toString()),
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
// 连接建立后注册设备(与 Android 端 onOpen -> registerDevice 对应)。
|
||||
_register();
|
||||
_startHeartbeat();
|
||||
onConnected?.call();
|
||||
} catch (e) {
|
||||
onError?.call(e.toString());
|
||||
}
|
||||
@@ -53,22 +69,45 @@ class SignalingClient {
|
||||
try {
|
||||
final map = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = SignalMessage.fromJson(map);
|
||||
// 记录服务端下发的本机 deviceId(REGISTER_SUCCESS.fromDeviceId)。
|
||||
if (message.type?.toUpperCase() == 'REGISTER_SUCCESS' &&
|
||||
message.fromDeviceId != null) {
|
||||
// 透传给上层,由 RemoteController 处理。
|
||||
}
|
||||
onMessage?.call(message);
|
||||
} catch (_) {
|
||||
// 忽略无法解析的消息。
|
||||
}
|
||||
}
|
||||
|
||||
void _register() {
|
||||
final msg = SignalMessage(
|
||||
type: 'REGISTER',
|
||||
fromDeviceId: deviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
);
|
||||
_send(msg);
|
||||
// 通知上层已“连接”(WebSocketChannel 会缓冲发送,直到底层连接就绪);
|
||||
// 后续 OFFER 等消息会排在 REGISTER 之后发送,保证服务器先完成注册。
|
||||
onConnected?.call();
|
||||
void _onDone() {
|
||||
_stopHeartbeat();
|
||||
final code = _channel?.closeCode;
|
||||
if (code == 4001) {
|
||||
onTokenExpired?.call();
|
||||
return;
|
||||
}
|
||||
if (code == 4003) {
|
||||
onForceLogout?.call();
|
||||
return;
|
||||
}
|
||||
onDisconnected?.call();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) {
|
||||
if (_channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({'type': 'PING'}));
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
/// 发送信令消息。
|
||||
@@ -80,6 +119,7 @@ class SignalingClient {
|
||||
|
||||
/// 关闭连接。
|
||||
void disconnect() {
|
||||
_stopHeartbeat();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import 'package:path_provider/path_provider.dart';
|
||||
/// 无需自行触碰原生 VideoSink。
|
||||
///
|
||||
/// 录制文件保存位置:
|
||||
/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
|
||||
/// - iOS:<App>/Documents/WebRTCRecordings/
|
||||
/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
|
||||
/// - iOS:<App>/Documents/WebRTCRecordings/
|
||||
class VideoRecorder {
|
||||
MediaRecorder? _recorder;
|
||||
bool _recording = false;
|
||||
|
||||
@@ -88,6 +88,9 @@ class WebRtcController {
|
||||
static const int streamModeWebRtc = 0;
|
||||
static const int streamModeSelfCodec = 1;
|
||||
|
||||
/// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空则用 kIceServers)。
|
||||
static List<Map<String, dynamic>>? iceServersOverride;
|
||||
|
||||
WebRtcController({
|
||||
required this.signaling,
|
||||
required this.deviceId,
|
||||
@@ -100,8 +103,9 @@ class WebRtcController {
|
||||
Future<void> initialize() async {
|
||||
await renderer.initialize();
|
||||
|
||||
final iceServers = iceServersOverride ?? kIceServers;
|
||||
final configuration = {
|
||||
'iceServers': kIceServers,
|
||||
'iceServers': iceServers,
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'iceCandidatePoolSize': 10,
|
||||
// 增强复杂网络下的稳定性,参考 Android 端配置。
|
||||
@@ -110,7 +114,7 @@ class WebRtcController {
|
||||
'tcpCandidatePolicy': 'enabled',
|
||||
};
|
||||
|
||||
final iceUrls = (kIceServers)
|
||||
final iceUrls = iceServers
|
||||
.map((e) => '${e['username'] != null ? '(${e['username']})' : ''}${e['urls']}')
|
||||
.join(', ');
|
||||
debugPrint('[WebRtcController] signaling server: ${signaling.serverUrl} | '
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
|
||||
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
flutter_webrtc
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import flutter_secure_storage_macos
|
||||
import flutter_webrtc
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
}
|
||||
|
||||
@@ -150,6 +150,54 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -176,6 +224,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -196,10 +260,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -44,6 +44,12 @@ dependencies:
|
||||
# WebSocket 信令通信
|
||||
web_socket_channel: ^3.0.3
|
||||
|
||||
# HTTP API(登录 / 刷新 / 绑定列表 / TURN 凭证)
|
||||
http: ^1.2.0
|
||||
|
||||
# 安全存储:refreshToken 存 Keychain / EncryptedSharedPreferences
|
||||
flutter_secure_storage: ^9.0.0
|
||||
|
||||
# 设备 ID 生成
|
||||
uuid: ^4.4.0
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
flutter_webrtc
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user