// SPDX-FileCopyrightText: 2026 TatuChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import 'package:tatchat/utils/miniapp_store.dart'; /// Wire protocol between an HTML mini app and the TatuChat client. /// /// The mini app sends a request object via `window.parent.postMessage`: /// ``` /// { /// "api": "fromMiniApp", /// "widgetId": "", /// "requestId": "...", /// "action": "", /// "data": /// } /// ``` /// The host answers with `{api, widgetId, requestId, response: {data, error}}`. class MiniappBridge { /// No-op instantiation guard; this class only holds static helpers. MiniappBridge._(); static const String api = 'fromMiniApp'; static const String actionLoad = 'io.tatuchat.storage.load'; static const String actionSave = 'io.tatuchat.storage.save'; static const String actionIdentity = 'io.tatuchat.identity.get'; static const String actionTheme = 'io.tatuchat.theme.get'; static const String actionAccountDataList = 'io.tatuchat.accountdata.list'; static const String actionAccountDataGet = 'io.tatuchat.accountdata.get'; static const String actionAccountDataSet = 'io.tatuchat.accountdata.set'; /// Handles one request coming from a mini app and returns the payload that /// gets sent back inside `response.data`, or `response.error` on failures. static Future> handleRequest( Client client, String appId, Map request, { Map? palette, }) async { final action = request['action']; final data = request['data']; switch (action) { case actionLoad: return {'data': client.getMiniAppData(appId)}; case actionSave: return _handleSave(client, appId, data); case actionIdentity: return _handleIdentity(client); case actionTheme: return { 'data': { 'palette': palette ?? const {}, 'brightness': palette?['brightness'] ?? 'light', }, }; case actionAccountDataList: return _handleAccountDataList(client); case actionAccountDataGet: return _handleAccountDataGet(client, data); case actionAccountDataSet: return _handleAccountDataSet(client, data); default: return const {'error': 'UNKNOWN_ACTION'}; } } /// Builds the theme palette map sent to mini apps. Colors are serialized as /// `#RRGGBBAA` hex strings (CSS 8-digit order) so they can be used directly /// in CSS/JS. static Map buildPalette( ColorScheme scheme, Brightness brightness, ) { String hex(Color c) { final value = c.toARGB32(); final rgb = value & 0xFFFFFF; final alpha = (value >> 24) & 0xFF; return '#${rgb .toRadixString(16) .padLeft(6, '0')}${alpha.toRadixString(16).padLeft(2, '0')}' .toUpperCase(); } return { 'brightness': brightness == Brightness.dark ? 'dark' : 'light', 'primary': hex(scheme.primary), 'onPrimary': hex(scheme.onPrimary), 'primaryContainer': hex(scheme.primaryContainer), 'onPrimaryContainer': hex(scheme.onPrimaryContainer), 'secondary': hex(scheme.secondary), 'onSecondary': hex(scheme.onSecondary), 'secondaryContainer': hex(scheme.secondaryContainer), 'onSecondaryContainer': hex(scheme.onSecondaryContainer), 'tertiary': hex(scheme.tertiary), 'onTertiary': hex(scheme.onTertiary), 'tertiaryContainer': hex(scheme.tertiaryContainer), 'onTertiaryContainer': hex(scheme.onTertiaryContainer), 'background': hex(scheme.surface), 'onBackground': hex(scheme.onSurface), 'surface': hex(scheme.surface), 'onSurface': hex(scheme.onSurface), 'surfaceVariant': hex(scheme.surfaceContainerHighest), 'onSurfaceVariant': hex(scheme.onSurfaceVariant), 'surfaceContainerLowest': hex(scheme.surfaceContainerLowest), 'surfaceContainerLow': hex(scheme.surfaceContainerLow), 'surfaceContainer': hex(scheme.surfaceContainer), 'surfaceContainerHigh': hex(scheme.surfaceContainerHigh), 'surfaceContainerHighest': hex(scheme.surfaceContainerHighest), 'outline': hex(scheme.outline), 'outlineVariant': hex(scheme.outlineVariant), 'error': hex(scheme.error), 'onError': hex(scheme.onError), 'errorContainer': hex(scheme.errorContainer), 'onErrorContainer': hex(scheme.onErrorContainer), 'inverseSurface': hex(scheme.inverseSurface), 'onInverseSurface': hex(scheme.onInverseSurface), 'shadow': hex(scheme.shadow), 'scrim': hex(scheme.scrim), }; } static Future> _handleSave( Client client, String appId, dynamic data, ) async { if (data is Map) { await client.setMiniAppData( appId, Map.from(data.map((k, v) => MapEntry('$k', v))), ); return const {'data': null}; } return const {'error': 'INVALID_DATA'}; } static Future> _handleIdentity(Client client) async { final profile = await client.fetchOwnProfile(); final avatarUrl = profile.avatarUrl; String? avatarDataUri; if (avatarUrl != null) { final downloadUri = await avatarUrl.getDownloadUri( client, skipScanner: true, ); if (downloadUri != Uri()) { try { final request = http.Request('GET', downloadUri); if (client.accessToken != null) { request.headers['authorization'] = 'Bearer ${client.accessToken}'; } final streamed = await client.httpClient.send(request); final response = await http.Response.fromStream(streamed); if (response.statusCode >= 200 && response.statusCode < 300) { final mimeType = response.headers['content-type']?.split(';').first ?? 'application/octet-stream'; avatarDataUri = 'data:$mimeType;base64,${base64Encode(response.bodyBytes)}'; } } catch (e) { Logs().d('Unable to download avatar for mini app', e); } } } return { 'data': { 'userId': client.userID?.toString(), 'displayName': profile.displayName ?? client.userID?.localpart.toString(), 'avatarUrl': avatarUrl?.toString(), 'avatarHttpUrl': avatarUrl == null ? null : (await avatarUrl.getDownloadUri( client, skipScanner: true, )) .toString(), 'avatarDataUri': avatarDataUri, 'homeserver': client.homeserver?.toString(), }, }; } static Map _handleAccountDataList(Client client) { return { 'data': client.accountData.map( (key, event) => MapEntry(key, event.content), ), }; } static Map _handleAccountDataGet( Client client, dynamic data, ) { final key = data is Map ? data['key'] : null; if (key is! String) return const {'error': 'INVALID_KEY'}; final event = client.accountData[key]; return { 'data': {'key': key, 'content': event?.content ?? const {}}, }; } static Future> _handleAccountDataSet( Client client, dynamic data, ) async { if (data is! Map) return const {'error': 'INVALID_DATA'}; final key = data['key']; final content = data['content']; if (key is! String || content is! Map) { return const {'error': 'INVALID_DATA'}; } await client.setAccountData( client.userID!, key, Map.from(content.map((k, v) => MapEntry('$k', v))), ); return const {'data': null}; } }