feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View 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内存保存掉线即失需重新登录
/// - refreshTokenflutter_secure_storageKeychain / 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;
}

View File

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

View File

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

View File

@@ -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);
// 记录服务端下发的本机 deviceIdREGISTER_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;
}

View File

@@ -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/&lt;包名&gt;/files/WebRTCRecordings/app 专属外部存储,无需存储权限)
/// - iOS&lt;App&gt;/Documents/WebRTCRecordings/
class VideoRecorder {
MediaRecorder? _recorder;
bool _recording = false;

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
flutter_webrtc
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
flutter_webrtc
)