109 lines
3.4 KiB
Dart
109 lines
3.4 KiB
Dart
// SPDX-FileCopyrightText: 2026 TatuChat
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
/// Names of the `<meta>` tags a mini app document must declare to be
|
|
/// installable. They act as the mini app's manifest.
|
|
const String miniAppMetaIdTag = 'mini-app-id';
|
|
const String miniAppMetaNameTag = 'mini-app-name';
|
|
const String miniAppMetaCategoryTag = 'mini-app-category';
|
|
const String miniAppMetaDescriptionTag = 'mini-app-description';
|
|
const String miniAppMetaLogoTag = 'mini-app-logo';
|
|
const String miniAppMetaInChatTag = 'mini-app-in-chat';
|
|
|
|
const List<String> miniAppMetaTags = [
|
|
miniAppMetaIdTag,
|
|
miniAppMetaNameTag,
|
|
miniAppMetaCategoryTag,
|
|
miniAppMetaDescriptionTag,
|
|
miniAppMetaLogoTag,
|
|
miniAppMetaInChatTag,
|
|
];
|
|
|
|
/// Manifest of a mini app, extracted from its `<meta>` tags.
|
|
///
|
|
/// [id] is mandatory and non-empty. It is used as the account data key under
|
|
/// which the app's store and HTML source are persisted
|
|
/// (`im.tatuchat.miniapps.data.<id>` / `im.tatuchat.miniapps.source.<id>`).
|
|
class MiniAppMeta {
|
|
final String id;
|
|
final String name;
|
|
final String category;
|
|
final String description;
|
|
final String logo;
|
|
|
|
/// Whether the mini app can run inside a chat room. `false` by default.
|
|
final bool inChat;
|
|
|
|
const MiniAppMeta({
|
|
required this.id,
|
|
required this.name,
|
|
this.category = '',
|
|
this.description = '',
|
|
this.logo = '',
|
|
this.inChat = false,
|
|
});
|
|
}
|
|
|
|
/// Extracts the meta tags of an HTML document into a `name -> content` map
|
|
/// (names normalized to lower case). Attribute order and quoting style are
|
|
/// irrelevant.
|
|
Map<String, String> extractMetaTags(String html) {
|
|
final result = <String, String>{};
|
|
final metaRegex = RegExp(r'<meta\b[^>]*>', caseSensitive: false);
|
|
final attrRegex = RegExp(
|
|
r"""(name|content)\s*=\s*["']([^"']*)["']""",
|
|
caseSensitive: false,
|
|
);
|
|
for (final match in metaRegex.allMatches(html)) {
|
|
final attributes = <String, String>{};
|
|
for (final attr in attrRegex.allMatches(match.group(0)!)) {
|
|
attributes[attr.group(1)!.toLowerCase()] = attr.group(2)!;
|
|
}
|
|
final name = attributes['name'];
|
|
final content = attributes['content'];
|
|
if (name != null && content != null) {
|
|
result[name.toLowerCase()] = content;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Parses the mini app manifest out of the `<meta>` tags of [html].
|
|
///
|
|
/// All [miniAppMetaTags] must be present (their content may be empty), except
|
|
/// `mini-app-id` and `mini-app-name`, which must also be non-empty. Throws a
|
|
/// [FormatException] with a human readable reason otherwise.
|
|
MiniAppMeta parseMiniAppMeta(String html) {
|
|
final tags = extractMetaTags(html);
|
|
final missing = [
|
|
for (final tag in miniAppMetaTags)
|
|
if (!tags.containsKey(tag)) tag,
|
|
];
|
|
if (missing.isNotEmpty) {
|
|
throw FormatException(
|
|
'This document is missing the required meta tag(s): '
|
|
'${missing.join(', ')}',
|
|
);
|
|
}
|
|
final id = tags[miniAppMetaIdTag]!.trim();
|
|
final name = tags[miniAppMetaNameTag]!.trim();
|
|
if (id.isEmpty) {
|
|
throw FormatException(
|
|
'The meta tag "$miniAppMetaIdTag" must not be empty',
|
|
);
|
|
}
|
|
if (name.isEmpty) {
|
|
throw FormatException(
|
|
'The meta tag "$miniAppMetaNameTag" must not be empty',
|
|
);
|
|
}
|
|
return MiniAppMeta(
|
|
id: id,
|
|
name: name,
|
|
category: tags[miniAppMetaCategoryTag]!.trim(),
|
|
description: tags[miniAppMetaDescriptionTag]!.trim(),
|
|
logo: tags[miniAppMetaLogoTag]!.trim(),
|
|
inChat: tags[miniAppMetaInChatTag]!.trim().toLowerCase() == 'true',
|
|
);
|
|
} |