784 lines
24 KiB
Dart
784 lines
24 KiB
Dart
// ignore_for_file: unused_import, unnecessary_import
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:web_socket_channel/io.dart';
|
|
import '../app/app_metadata.dart';
|
|
import 'device_identity_store.dart';
|
|
import 'platform_environment.dart';
|
|
import 'runtime_models.dart';
|
|
import 'secure_config_store.dart';
|
|
import 'gateway_runtime_protocol.dart';
|
|
import 'gateway_runtime_events.dart';
|
|
import 'gateway_runtime_errors.dart';
|
|
import 'gateway_runtime_core.dart';
|
|
|
|
String formatGatewayConnectAuthSummary({
|
|
required String mode,
|
|
required List<String> fields,
|
|
required List<String> sources,
|
|
}) {
|
|
final resolvedFields = fields.isEmpty ? 'none' : fields.join(', ');
|
|
final resolvedSources = sources.isEmpty ? 'none' : sources.join(' · ');
|
|
return '$mode | fields: $resolvedFields | sources: $resolvedSources';
|
|
}
|
|
|
|
mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
|
|
Future<RpcResponseInternal> requestRawInternal(
|
|
GatewayRuntime runtime,
|
|
String method, {
|
|
Map<String, dynamic>? params,
|
|
Duration timeout = const Duration(seconds: 15),
|
|
}) async {
|
|
final channel = runtime.channelInternal;
|
|
if (channel == null) {
|
|
throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE');
|
|
}
|
|
final id =
|
|
'${DateTime.now().microsecondsSinceEpoch}-${runtime.requestCounterInternal++}';
|
|
final completer = Completer<RpcResponseInternal>();
|
|
runtime.pendingInternal[id] = completer;
|
|
final frame = <String, dynamic>{
|
|
'type': 'req',
|
|
'id': id,
|
|
'method': method,
|
|
...?params == null ? null : <String, dynamic>{'params': params},
|
|
};
|
|
channel.sink.add(jsonEncode(frame));
|
|
try {
|
|
return await completer.future.timeout(
|
|
timeout,
|
|
onTimeout: () => throw GatewayRuntimeException(
|
|
'$method request timeout',
|
|
code: 'RPC_TIMEOUT',
|
|
),
|
|
);
|
|
} finally {
|
|
runtime.pendingInternal.remove(id);
|
|
}
|
|
}
|
|
|
|
GatewayPendingDevice parsePendingDeviceInternal(Map<String, dynamic> map) {
|
|
return GatewayPendingDevice(
|
|
requestId: stringValue(map['requestId']) ?? randomIdInternal(),
|
|
deviceId: stringValue(map['deviceId']) ?? 'unknown-device',
|
|
displayName: stringValue(map['displayName']),
|
|
role: stringValue(map['role']),
|
|
scopes: stringList(map['scopes']),
|
|
remoteIp: stringValue(map['remoteIp']),
|
|
isRepair: boolValue(map['isRepair']) ?? false,
|
|
requestedAtMs: intValue(map['ts']),
|
|
);
|
|
}
|
|
|
|
GatewayPairedDevice parsePairedDeviceInternal(
|
|
Map<String, dynamic> map, {
|
|
String? currentDeviceId,
|
|
}) {
|
|
return GatewayPairedDevice(
|
|
deviceId: stringValue(map['deviceId']) ?? 'unknown-device',
|
|
displayName: stringValue(map['displayName']),
|
|
roles: stringList(map['roles']),
|
|
scopes: stringList(map['scopes']),
|
|
remoteIp: stringValue(map['remoteIp']),
|
|
tokens: asList(map['tokens'])
|
|
.map((item) => parseTokenSummaryInternal(asMap(item)))
|
|
.toList(growable: false),
|
|
createdAtMs: intValue(map['createdAtMs']),
|
|
approvedAtMs: intValue(map['approvedAtMs']),
|
|
currentDevice:
|
|
currentDeviceId != null &&
|
|
currentDeviceId.isNotEmpty &&
|
|
currentDeviceId == stringValue(map['deviceId']),
|
|
);
|
|
}
|
|
|
|
GatewayDeviceTokenSummary parseTokenSummaryInternal(
|
|
Map<String, dynamic> map,
|
|
) {
|
|
return GatewayDeviceTokenSummary(
|
|
role: stringValue(map['role']) ?? 'operator',
|
|
scopes: stringList(map['scopes']),
|
|
createdAtMs: intValue(map['createdAtMs']),
|
|
rotatedAtMs: intValue(map['rotatedAtMs']),
|
|
revokedAtMs: intValue(map['revokedAtMs']),
|
|
lastUsedAtMs: intValue(map['lastUsedAtMs']),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> buildConnectParamsInternal(
|
|
GatewayRuntime runtime, {
|
|
required GatewayConnectionProfile profile,
|
|
required LocalDeviceIdentity identity,
|
|
required String nonce,
|
|
required String authToken,
|
|
required String authDeviceToken,
|
|
required String authPassword,
|
|
}) async {
|
|
final clientId = resolveClientIdInternal();
|
|
final clientMode = 'ui';
|
|
final signedAtMs = DateTime.now().millisecondsSinceEpoch;
|
|
final signaturePayload = runtime.identityStoreInternal
|
|
.buildDeviceAuthPayloadV3(
|
|
deviceId: identity.deviceId,
|
|
clientId: clientId,
|
|
clientMode: clientMode,
|
|
role: 'operator',
|
|
scopes: kDefaultOperatorConnectScopes,
|
|
signedAtMs: signedAtMs,
|
|
token: authToken,
|
|
nonce: nonce,
|
|
platform: runtime.deviceInfoInternal.platformLabel,
|
|
deviceFamily: runtime.deviceInfoInternal.deviceFamily,
|
|
);
|
|
final signature = await runtime.identityStoreInternal.signPayload(
|
|
identity: identity,
|
|
payload: signaturePayload,
|
|
);
|
|
|
|
return <String, dynamic>{
|
|
'minProtocol': kGatewayProtocolVersion,
|
|
'maxProtocol': kGatewayProtocolVersion,
|
|
'client': <String, dynamic>{
|
|
'id': clientId,
|
|
'displayName':
|
|
'$kSystemAppName ${runtime.deviceInfoInternal.deviceFamily}',
|
|
'version': runtime.packageInfoInternal.version,
|
|
'platform': runtime.deviceInfoInternal.platformLabel,
|
|
'deviceFamily': runtime.deviceInfoInternal.deviceFamily,
|
|
'modelIdentifier': runtime.deviceInfoInternal.modelIdentifier,
|
|
'mode': clientMode,
|
|
'instanceId':
|
|
'$clientId-${identity.deviceId.substring(0, min(8, identity.deviceId.length))}',
|
|
},
|
|
'caps': const <String>['tool-events'],
|
|
'commands': const <String>[],
|
|
'permissions': const <String, bool>{},
|
|
'role': 'operator',
|
|
'scopes': kDefaultOperatorConnectScopes,
|
|
if (authToken.isNotEmpty ||
|
|
authDeviceToken.isNotEmpty ||
|
|
authPassword.isNotEmpty)
|
|
'auth': <String, dynamic>{
|
|
if (authToken.isNotEmpty) 'token': authToken,
|
|
if (authDeviceToken.isNotEmpty) 'deviceToken': authDeviceToken,
|
|
if (authPassword.isNotEmpty) 'password': authPassword,
|
|
},
|
|
'locale': Platform.localeName,
|
|
'userAgent': '$kSystemAppName/${runtime.packageInfoInternal.version}',
|
|
'device': <String, dynamic>{
|
|
'id': identity.deviceId,
|
|
'publicKey': identity.publicKeyBase64Url,
|
|
'signature': signature,
|
|
'signedAt': signedAtMs,
|
|
'nonce': nonce,
|
|
},
|
|
};
|
|
}
|
|
|
|
(String, int, bool)? resolveEndpointInternal(
|
|
GatewayConnectionProfile profile,
|
|
) {
|
|
final payload = decodeGatewaySetupCode(profile.setupCode);
|
|
if (profile.useSetupCode && payload != null) {
|
|
return (payload.host, payload.port, payload.tls);
|
|
}
|
|
final host = profile.host.trim();
|
|
if (host.isEmpty) {
|
|
return null;
|
|
}
|
|
final normalized = parseGatewayEndpoint(
|
|
host.contains('://')
|
|
? host
|
|
: composeManualUrlInternal(host, profile.port, profile.tls),
|
|
);
|
|
return normalized ?? (host, profile.port, profile.tls);
|
|
}
|
|
|
|
void handleIncomingInternal(
|
|
GatewayRuntime runtime,
|
|
dynamic raw,
|
|
Completer<String> challenge,
|
|
) {
|
|
final text = raw is String ? raw : utf8.decode(raw as List<int>);
|
|
final decoded = jsonDecode(text) as Map<String, dynamic>;
|
|
|
|
// Handle Events / Notifications
|
|
final event = stringValue(decoded['event']) ?? stringValue(decoded['method']);
|
|
final type = stringValue(decoded['type']);
|
|
|
|
if (event != null || type == 'event') {
|
|
final resolvedEvent = event ?? '';
|
|
final payload = decoded['payload'] ?? decoded['params'];
|
|
if (resolvedEvent == 'connect.challenge') {
|
|
final nonce = stringValue(asMap(payload)['nonce']);
|
|
if (nonce != null && !challenge.isCompleted) {
|
|
challenge.complete(nonce);
|
|
}
|
|
appendLogInternal(runtime, 'debug', 'connect', 'challenge received');
|
|
return;
|
|
}
|
|
if (resolvedEvent == 'health') {
|
|
runtime.snapshotInternal = runtime.snapshotInternal.copyWith(
|
|
healthPayload: asMap(payload),
|
|
);
|
|
appendLogInternal(runtime, 'debug', 'health', 'push health update');
|
|
runtime.notifyListeners();
|
|
} else if (resolvedEvent == 'device.pair.requested' ||
|
|
resolvedEvent == 'device.pair.resolved') {
|
|
final eventPayload = asMap(payload);
|
|
appendLogInternal(
|
|
runtime,
|
|
'info',
|
|
'pairing',
|
|
'$resolvedEvent | request: ${stringValue(eventPayload['requestId']) ?? 'unknown'} | device: ${stringValue(eventPayload['deviceId']) ?? 'unknown'}',
|
|
);
|
|
} else if (resolvedEvent == 'seqGap') {
|
|
appendLogInternal(runtime, 'warn', 'sync', 'sequence gap detected');
|
|
}
|
|
runtime.eventsInternal.add(
|
|
GatewayPushEvent(
|
|
event: resolvedEvent,
|
|
payload: payload,
|
|
sequence: intValue(decoded['seq']) ?? 0,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Handle Responses
|
|
final id = stringValue(decoded['id']);
|
|
if (id == null) {
|
|
return;
|
|
}
|
|
final completer = runtime.pendingInternal.remove(id);
|
|
if (completer == null || completer.isCompleted) {
|
|
return;
|
|
}
|
|
|
|
final hasResult = decoded.containsKey('result');
|
|
final hasError = decoded.containsKey('error');
|
|
final ok = boolValue(decoded['ok']) ?? (hasResult && !hasError);
|
|
final payload = decoded['payload'] ?? decoded['result'];
|
|
final error = asMap(decoded['error']);
|
|
|
|
if (!ok) {
|
|
appendLogInternal(
|
|
runtime,
|
|
'error',
|
|
'rpc',
|
|
'request failed | code: ${stringValue(error['code']) ?? 'unknown'} | detail: ${stringValue(asMap(error['details'])['code']) ?? 'none'} | message: ${stringValue(error['message']) ?? 'gateway request failed'}',
|
|
);
|
|
if (!shouldAutoReconnectForCodesInternal(
|
|
stringValue(error['code']),
|
|
stringValue(asMap(error['details'])['code']),
|
|
)) {
|
|
runtime.suppressReconnectInternal = true;
|
|
}
|
|
completer.completeError(
|
|
GatewayRuntimeException(
|
|
stringValue(error['message']) ?? 'gateway request failed',
|
|
code: stringValue(error['code']),
|
|
details: error['details'],
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
completer.complete(
|
|
RpcResponseInternal(ok: ok, payload: payload, error: error),
|
|
);
|
|
}
|
|
|
|
void handleSocketFailureInternal(GatewayRuntime runtime, String message) {
|
|
failPendingInternal(
|
|
runtime,
|
|
GatewayRuntimeException(message, code: 'SOCKET_FAILURE'),
|
|
);
|
|
if (runtime.manualDisconnectInternal || runtime.suppressReconnectInternal) {
|
|
appendLogInternal(
|
|
runtime,
|
|
'warn',
|
|
'socket',
|
|
'failure ignored for reconnect | manual: ${runtime.manualDisconnectInternal} | suppressed: ${runtime.suppressReconnectInternal} | message: $message',
|
|
);
|
|
return;
|
|
}
|
|
appendLogInternal(runtime, 'error', 'socket', 'failure | $message');
|
|
runtime.snapshotInternal = runtime.snapshotInternal.copyWith(
|
|
status: RuntimeConnectionStatus.error,
|
|
statusText: 'Gateway error',
|
|
lastError: message,
|
|
lastErrorCode: 'SOCKET_FAILURE',
|
|
lastErrorDetailCode: null,
|
|
);
|
|
runtime.notifyListeners();
|
|
scheduleReconnectInternal(runtime);
|
|
}
|
|
|
|
void handleSocketClosedInternal(GatewayRuntime runtime) {
|
|
failPendingInternal(
|
|
runtime,
|
|
GatewayRuntimeException('socket closed', code: 'SOCKET_CLOSED'),
|
|
);
|
|
if (runtime.manualDisconnectInternal || runtime.suppressReconnectInternal) {
|
|
appendLogInternal(
|
|
runtime,
|
|
'warn',
|
|
'socket',
|
|
'closed without reconnect | manual: ${runtime.manualDisconnectInternal} | suppressed: ${runtime.suppressReconnectInternal}',
|
|
);
|
|
return;
|
|
}
|
|
appendLogInternal(runtime, 'warn', 'socket', 'closed by gateway');
|
|
runtime.snapshotInternal = runtime.snapshotInternal.copyWith(
|
|
status: RuntimeConnectionStatus.error,
|
|
statusText: 'Disconnected',
|
|
lastError: 'Gateway connection closed',
|
|
lastErrorCode: 'SOCKET_CLOSED',
|
|
lastErrorDetailCode: null,
|
|
);
|
|
runtime.notifyListeners();
|
|
scheduleReconnectInternal(runtime);
|
|
}
|
|
|
|
String cronScheduleLabelInternal(Map<String, dynamic> schedule) {
|
|
final kind = stringValue(schedule['kind']) ?? '';
|
|
return switch (kind) {
|
|
'at' => stringValue(schedule['at']) ?? 'at',
|
|
'every' => '${intValue(schedule['everyMs']) ?? 0}ms',
|
|
'cron' => stringValue(schedule['expr']) ?? 'cron',
|
|
_ => 'unknown',
|
|
};
|
|
}
|
|
|
|
void scheduleReconnectInternal(GatewayRuntime runtime) {
|
|
final profile = runtime.desiredProfileInternal;
|
|
if (runtime.manualDisconnectInternal ||
|
|
runtime.suppressReconnectInternal ||
|
|
profile == null) {
|
|
return;
|
|
}
|
|
runtime.reconnectTimerInternal?.cancel();
|
|
runtime.reconnectTimerInternal = Timer(const Duration(seconds: 2), () {
|
|
appendLogInternal(
|
|
runtime,
|
|
'info',
|
|
'socket',
|
|
'reconnect firing | host: ${profile.host.trim().isEmpty ? 'setup-code' : profile.host.trim()} | port: ${profile.port}',
|
|
);
|
|
unawaited(runtime.connectProfile(profile));
|
|
});
|
|
}
|
|
|
|
bool shouldAutoReconnectInternal(GatewayRuntimeException? error) {
|
|
return shouldAutoReconnectForCodesInternal(error?.code, error?.detailCode);
|
|
}
|
|
|
|
bool shouldAutoReconnectForCodesInternal(String? code, String? detailCode) {
|
|
final resolvedCode = code?.trim().toUpperCase();
|
|
final resolvedDetailCode = detailCode?.trim().toUpperCase();
|
|
const nonRetryableCodes = <String>{
|
|
'INVALID_REQUEST',
|
|
'UNAUTHORIZED',
|
|
'NOT_PAIRED',
|
|
'AUTH_REQUIRED',
|
|
};
|
|
const nonRetryableDetailCodes = <String>{
|
|
'AUTH_REQUIRED',
|
|
'AUTH_UNAUTHORIZED',
|
|
'AUTH_TOKEN_MISSING',
|
|
'AUTH_TOKEN_MISMATCH',
|
|
'AUTH_PASSWORD_MISSING',
|
|
'AUTH_PASSWORD_MISMATCH',
|
|
'AUTH_DEVICE_TOKEN_MISMATCH',
|
|
'PAIRING_REQUIRED',
|
|
'DEVICE_IDENTITY_REQUIRED',
|
|
'CONTROL_UI_DEVICE_IDENTITY_REQUIRED',
|
|
};
|
|
if (resolvedCode != null && nonRetryableCodes.contains(resolvedCode)) {
|
|
return false;
|
|
}
|
|
if (resolvedDetailCode != null &&
|
|
nonRetryableDetailCodes.contains(resolvedDetailCode)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool isPairingRequiredErrorInternal(String? code, String? detailCode) {
|
|
final resolvedCode = code?.trim().toUpperCase();
|
|
final resolvedDetailCode = detailCode?.trim().toUpperCase();
|
|
return resolvedCode == 'NOT_PAIRED' ||
|
|
resolvedDetailCode == 'PAIRING_REQUIRED';
|
|
}
|
|
|
|
Future<void> closeSocketInternal(GatewayRuntime runtime) async {
|
|
runtime.reconnectTimerInternal?.cancel();
|
|
final subscription = runtime.socketSubscriptionInternal;
|
|
runtime.socketSubscriptionInternal = null;
|
|
await subscription?.cancel();
|
|
await runtime.channelInternal?.sink.close();
|
|
runtime.channelInternal = null;
|
|
failPendingInternal(
|
|
runtime,
|
|
GatewayRuntimeException('socket reset', code: 'SOCKET_RESET'),
|
|
);
|
|
}
|
|
|
|
void appendLogInternal(
|
|
GatewayRuntime runtime,
|
|
String level,
|
|
String category,
|
|
String message,
|
|
) {
|
|
runtime.logsInternal.add(
|
|
RuntimeLogEntry(
|
|
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
|
level: level,
|
|
category: category,
|
|
message: message,
|
|
),
|
|
);
|
|
const maxLogEntries = 250;
|
|
if (runtime.logsInternal.length > maxLogEntries) {
|
|
runtime.logsInternal.removeRange(
|
|
0,
|
|
runtime.logsInternal.length - maxLogEntries,
|
|
);
|
|
}
|
|
runtime.notifyListeners();
|
|
}
|
|
|
|
String connectAuthSummaryInternal({
|
|
required String mode,
|
|
required List<String> fields,
|
|
required List<String> sources,
|
|
}) {
|
|
final resolvedFields = fields.isEmpty ? 'none' : fields.join(', ');
|
|
final resolvedSources = sources.isEmpty ? 'none' : sources.join(' · ');
|
|
return '$mode | fields: $resolvedFields | sources: $resolvedSources';
|
|
}
|
|
|
|
void failPendingInternal(GatewayRuntime runtime, Object error) {
|
|
final values = runtime.pendingInternal.values.toList(growable: false);
|
|
runtime.pendingInternal.clear();
|
|
for (final completer in values) {
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
String resolveClientIdInternal() {
|
|
return resolveGatewayClientId();
|
|
}
|
|
|
|
Future<RuntimePackageInfo> loadPackageInfoInternal() async {
|
|
try {
|
|
final info = await PackageInfo.fromPlatform();
|
|
return RuntimePackageInfo(
|
|
appName: info.appName,
|
|
packageName: info.packageName,
|
|
version: info.version,
|
|
buildNumber: info.buildNumber,
|
|
);
|
|
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
|
|
return const RuntimePackageInfo(
|
|
appName: kSystemAppName,
|
|
packageName: 'plus.svc.xworkmate',
|
|
version: kAppVersion,
|
|
buildNumber: kAppBuildNumber,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<RuntimeDeviceInfo> loadDeviceInfoInternal() async {
|
|
final plugin = DeviceInfoPlugin();
|
|
try {
|
|
if (Platform.isIOS) {
|
|
final info = await plugin.iosInfo;
|
|
return RuntimeDeviceInfo(
|
|
platform: 'ios',
|
|
platformVersion: info.systemVersion,
|
|
deviceFamily: info.model,
|
|
modelIdentifier: info.utsname.machine,
|
|
);
|
|
}
|
|
if (Platform.isMacOS) {
|
|
final info = await plugin.macOsInfo;
|
|
return RuntimeDeviceInfo(
|
|
platform: 'macos',
|
|
platformVersion:
|
|
'${info.majorVersion}.${info.minorVersion}.${info.patchVersion}',
|
|
deviceFamily: 'Mac',
|
|
modelIdentifier: info.model,
|
|
);
|
|
}
|
|
if (Platform.isAndroid) {
|
|
final info = await plugin.androidInfo;
|
|
return RuntimeDeviceInfo(
|
|
platform: 'android',
|
|
platformVersion: info.version.release,
|
|
deviceFamily: info.model,
|
|
modelIdentifier: info.id,
|
|
);
|
|
}
|
|
if (Platform.isWindows) {
|
|
final info = await plugin.windowsInfo;
|
|
return RuntimeDeviceInfo(
|
|
platform: 'windows',
|
|
platformVersion: info.displayVersion,
|
|
deviceFamily: 'Windows',
|
|
modelIdentifier: info.computerName,
|
|
);
|
|
}
|
|
if (Platform.isLinux) {
|
|
final info = await plugin.linuxInfo;
|
|
return RuntimeDeviceInfo(
|
|
platform: 'linux',
|
|
platformVersion: info.version ?? '',
|
|
deviceFamily: 'Linux',
|
|
modelIdentifier: info.machineId ?? 'linux',
|
|
);
|
|
}
|
|
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
|
|
// Fall through to generic info.
|
|
}
|
|
return RuntimeDeviceInfo(
|
|
platform: Platform.operatingSystem,
|
|
platformVersion: Platform.operatingSystemVersion,
|
|
deviceFamily: Platform.operatingSystem,
|
|
modelIdentifier: Platform.localHostname,
|
|
);
|
|
}
|
|
}
|
|
|
|
class GatewaySetupPayload {
|
|
const GatewaySetupPayload({
|
|
required this.host,
|
|
required this.port,
|
|
required this.tls,
|
|
required this.token,
|
|
required this.password,
|
|
});
|
|
|
|
final String host;
|
|
final int port;
|
|
final bool tls;
|
|
final String token;
|
|
final String password;
|
|
}
|
|
|
|
GatewaySetupPayload? decodeGatewaySetupCode(String rawInput) {
|
|
final trimmed = rawInput.trim();
|
|
if (trimmed.isEmpty) {
|
|
return null;
|
|
}
|
|
final candidate = resolveSetupCodeCandidateInternal(trimmed);
|
|
final direct = decodeSetupPayloadJsonInternal(candidate);
|
|
if (direct != null) {
|
|
return direct;
|
|
}
|
|
try {
|
|
final normalized = candidate.replaceAll('-', '+').replaceAll('_', '/');
|
|
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
|
|
final decoded = utf8.decode(base64.decode(padded));
|
|
return decodeSetupPayloadJsonInternal(decoded);
|
|
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
GatewaySetupPayload? decodeSetupPayloadJsonInternal(String raw) {
|
|
try {
|
|
final json = jsonDecode(raw) as Map<String, dynamic>;
|
|
final url = stringValue(json['url']);
|
|
final host = stringValue(json['host']);
|
|
final port = intValue(json['port']);
|
|
final tls = boolValue(json['tls']);
|
|
final resolved = parseGatewayEndpoint(
|
|
url ?? composeManualUrlInternal(host, port, tls),
|
|
);
|
|
if (resolved == null) {
|
|
return null;
|
|
}
|
|
return GatewaySetupPayload(
|
|
host: resolved.$1,
|
|
port: resolved.$2,
|
|
tls: resolved.$3,
|
|
token: stringValue(json['token']) ?? '',
|
|
password: stringValue(json['password']) ?? '',
|
|
);
|
|
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String resolveSetupCodeCandidateInternal(String raw) {
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is Map<String, dynamic>) {
|
|
return stringValue(decoded['setupCode']) ?? raw;
|
|
}
|
|
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
|
|
// Leave raw as-is.
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
(String, int, bool)? parseGatewayEndpoint(String? rawInput) {
|
|
final raw = rawInput?.trim() ?? '';
|
|
if (raw.isEmpty) {
|
|
return null;
|
|
}
|
|
final normalized = raw.contains('://') ? raw : 'https://$raw';
|
|
final uri = Uri.tryParse(normalized);
|
|
final host = uri?.host.trim() ?? '';
|
|
if (host.isEmpty) {
|
|
return null;
|
|
}
|
|
final scheme = uri?.scheme.trim().toLowerCase() ?? 'https';
|
|
final tls = switch (scheme) {
|
|
'ws' || 'http' => false,
|
|
_ => true,
|
|
};
|
|
final parsedPort = uri?.port;
|
|
final port = parsedPort != null && parsedPort >= 1 && parsedPort <= 65535
|
|
? parsedPort
|
|
: (tls ? 443 : 18789);
|
|
return (host, port, tls);
|
|
}
|
|
|
|
String? composeManualUrlInternal(String? host, int? port, bool? tls) {
|
|
final trimmedHost = host?.trim() ?? '';
|
|
if (trimmedHost.isEmpty) {
|
|
return null;
|
|
}
|
|
final resolvedPort = port ?? 18789;
|
|
final scheme = tls == false ? 'http' : 'https';
|
|
return '$scheme://$trimmedHost:$resolvedPort';
|
|
}
|
|
|
|
Map<String, dynamic> asMap(Object? value) {
|
|
if (value is Map<String, dynamic>) {
|
|
return value;
|
|
}
|
|
if (value is Map) {
|
|
return value.cast<String, dynamic>();
|
|
}
|
|
return const <String, dynamic>{};
|
|
}
|
|
|
|
List<dynamic> asList(Object? value) {
|
|
if (value is List<dynamic>) {
|
|
return value;
|
|
}
|
|
if (value is List) {
|
|
return value.cast<dynamic>();
|
|
}
|
|
return const <dynamic>[];
|
|
}
|
|
|
|
String? stringValue(Object? value) {
|
|
if (value is String) {
|
|
final trimmed = value.trim();
|
|
return trimmed.isEmpty ? null : trimmed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool? boolValue(Object? value) {
|
|
if (value is bool) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
switch (value.trim().toLowerCase()) {
|
|
case 'true':
|
|
return true;
|
|
case 'false':
|
|
return false;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
int? intValue(Object? value) {
|
|
if (value is int) {
|
|
return value;
|
|
}
|
|
if (value is double) {
|
|
return value.toInt();
|
|
}
|
|
if (value is String) {
|
|
return int.tryParse(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
double? doubleValue(Object? value) {
|
|
if (value is double) {
|
|
return value;
|
|
}
|
|
if (value is int) {
|
|
return value.toDouble();
|
|
}
|
|
if (value is String) {
|
|
return double.tryParse(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<String> stringList(Object? value) {
|
|
return asList(
|
|
value,
|
|
).map(stringValue).whereType<String>().toList(growable: false);
|
|
}
|
|
|
|
String extractMessageText(Map<String, dynamic> message) {
|
|
final directContent = message['content'];
|
|
if (directContent is String) {
|
|
return directContent;
|
|
}
|
|
final parts = <String>[];
|
|
for (final part in asList(directContent)) {
|
|
final map = asMap(part);
|
|
final text = stringValue(map['text']) ?? stringValue(map['thinking']);
|
|
if (text != null && text.isNotEmpty) {
|
|
parts.add(text);
|
|
continue;
|
|
}
|
|
final nestedContent = map['content'];
|
|
if (nestedContent is String && nestedContent.trim().isNotEmpty) {
|
|
parts.add(nestedContent.trim());
|
|
}
|
|
}
|
|
return parts.join('\n').trim();
|
|
}
|
|
|
|
String randomIdInternal() {
|
|
final random = Random.secure();
|
|
final timestamp = DateTime.now().microsecondsSinceEpoch.toRadixString(16);
|
|
final suffix = List<int>.generate(
|
|
6,
|
|
(_) => random.nextInt(256),
|
|
).map((value) => value.toRadixString(16).padLeft(2, '0')).join();
|
|
return '$timestamp-$suffix';
|
|
}
|
|
|
|
class RpcResponseInternal {
|
|
const RpcResponseInternal({
|
|
required this.ok,
|
|
required this.payload,
|
|
required this.error,
|
|
});
|
|
|
|
final bool ok;
|
|
final dynamic payload;
|
|
final Map<String, dynamic> error;
|
|
}
|