218 lines
7.5 KiB
Dart
218 lines
7.5 KiB
Dart
// 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": "<appId>",
|
|
/// "requestId": "...",
|
|
/// "action": "<action>",
|
|
/// "data": <any>
|
|
/// }
|
|
/// ```
|
|
/// 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<Map<String, Object?>> handleRequest(
|
|
Client client,
|
|
String appId,
|
|
Map<String, dynamic> request, {
|
|
Map<String, Object?>? 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
|
|
/// `#AARRGGBB` hex strings so they can be used directly in CSS/JS.
|
|
static Map<String, Object?> buildPalette(ColorScheme scheme, Brightness brightness) {
|
|
String hex(Color c) {
|
|
final value = c.toARGB32();
|
|
return '#${value.toRadixString(16).padLeft(8, '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<Map<String, Object?>> _handleSave(
|
|
Client client,
|
|
String appId,
|
|
dynamic data,
|
|
) async {
|
|
if (data is Map) {
|
|
await client.setMiniAppData(
|
|
appId,
|
|
Map<String, Object?>.from(data.map((k, v) => MapEntry('$k', v))),
|
|
);
|
|
return const {'data': null};
|
|
}
|
|
return const {'error': 'INVALID_DATA'};
|
|
}
|
|
|
|
static Future<Map<String, Object?>> _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<String, Object?> _handleAccountDataList(Client client) {
|
|
return {
|
|
'data': client.accountData.map(
|
|
(key, event) => MapEntry(key, event.content),
|
|
),
|
|
};
|
|
}
|
|
|
|
static Map<String, Object?> _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<Map<String, Object?>> _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<String, Object?>.from(content.map((k, v) => MapEntry('$k', v))),
|
|
);
|
|
return const {'data': null};
|
|
}
|
|
} |