feat: mini apps customizados com importacao por arquivo/URL, paleta de cores, avatar autenticado e documentacao
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-18 15:11:12 -03:00
parent 94632e0611
commit 1f692b1e4e
18 changed files with 2186 additions and 2 deletions

View File

@@ -0,0 +1,218 @@
// 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};
}
}

View File

@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:matrix/matrix.dart';
/// Account data key that holds the list of installed mini app IDs.
const String installedMiniAppsKey = 'im.tatuchat.miniapps.installed';
/// Prefix of the account data keys that hold a mini app's JSON document.
const String miniAppDataKeyPrefix = 'im.tatuchat.miniapps.data.';
/// Prefix of the account data keys that hold the HTML source of an imported
/// mini app. The key is ``{prefix}{appId}`` and the content is
/// `{name: '...', html: '...'}`.
const String miniAppSourceKeyPrefix = 'im.tatuchat.miniapps.source.';
/// Extension that gives every [Client] access to the mini apps store which is
/// persisted in the user's account data ("localStorage on the server").
extension MiniAppStoreExtension on Client {
List<String> get installedMiniApps {
final apps = accountData[installedMiniAppsKey]?.content['apps'];
if (apps is List) return [...apps.whereType<String>()];
return [];
}
bool isMiniAppInstalled(String appId) => installedMiniApps.contains(appId);
Future<void> installMiniApp(String appId) async {
final apps = installedMiniApps;
if (apps.contains(appId)) return;
apps.add(appId);
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
}
Future<void> uninstallMiniApp(String appId) async {
final apps = installedMiniApps..remove(appId);
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
// Clear the custom HTML source if present.
await setAccountData(userID!, '$miniAppSourceKeyPrefix$appId', {
'html': '',
'name': '',
});
}
/// Reads the JSON document of the given mini app. Returns an empty map if
/// the app never stored anything.
Map<String, dynamic> getMiniAppData(String appId) =>
accountData['$miniAppDataKeyPrefix$appId']?.content ?? const {};
/// Overwrites the JSON document of the given mini app.
Future<void> setMiniAppData(String appId, Map<String, Object?> data) =>
setAccountData(userID!, '$miniAppDataKeyPrefix$appId', data);
// ---------- Imported (custom) apps ----------
/// Stores the HTML source and a display name for a user-imported mini app
/// and adds it to the installed list if not already present.
Future<void> importMiniApp({
required String appId,
required String name,
required String html,
}) async {
await setAccountData(userID!, '$miniAppSourceKeyPrefix$appId', {
'name': name,
'html': html,
});
final apps = installedMiniApps;
if (!apps.contains(appId)) {
apps.add(appId);
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
}
}
/// Returns the raw HTML source of an imported mini app, or null if the app
/// is a built-in app or was never imported.
String? getMiniAppSource(String appId) {
final content =
accountData['$miniAppSourceKeyPrefix$appId']?.content;
if (content == null) return null;
final html = content['html'];
return (html is String && html.isNotEmpty) ? html : null;
}
/// Returns the display name of an imported mini app, or null.
String? getMiniAppName(String appId) {
final content =
accountData['$miniAppSourceKeyPrefix$appId']?.content;
if (content == null) return null;
final name = content['name'];
return (name is String && name.isNotEmpty) ? name : null;
}
}