diff --git a/assets/miniapps/accountdata.html b/assets/miniapps/accountdata.html new file mode 100644 index 0000000..bb76bfb --- /dev/null +++ b/assets/miniapps/accountdata.html @@ -0,0 +1,478 @@ + + + + + +Account Data + + + +
+
+
+

Account Data

+
+ +
+ +
+

Keys

+
+ + +
+
+ + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/assets/miniapps/tasks.html b/assets/miniapps/tasks.html new file mode 100644 index 0000000..cba45ba --- /dev/null +++ b/assets/miniapps/tasks.html @@ -0,0 +1,209 @@ + + + + + +Tasks + + + +
+
+
+

Tasks

+
+
+ + +
+ +
+ + + \ No newline at end of file diff --git a/docs/miniapps.md b/docs/miniapps.md new file mode 100644 index 0000000..d44f844 --- /dev/null +++ b/docs/miniapps.md @@ -0,0 +1,178 @@ +# TatuChat Mini Apps + +Mini apps are small, self-contained HTML documents that run inside TatuChat in a +sandboxed web view. They can read/write their own key-value store, inspect the +user's profile and read/write arbitrary account data through a tiny messaging +bridge. There are no dependencies and no build step: an app is just one `.html` +file. + +## Creating a mini app + +1. Write a single `.html` file (assets may be inlined; see limitations below). +2. Import it in TatuChat: + - **From file:** open *Apps* → the **import** menu → *Import from file* and + pick your `.html` file. + - **From URL:** *Apps* → import menu → *Import from URL* and paste a link to + the HTML document. +3. Open the app from the *Installed* list. Use the reload button in the top bar + while developing. + +The available apps page (`/rooms/apps`) lists the built-in apps (`tasks`, +`accountdata`) under *Installed* once you install them, plus every app you +import. + +## Talking to TatuChat + +The host listens for `message` events. A channel app sends a request with +`window.parent.postMessage`, and the host answers with the same `requestId`. +Replies are delivered in an event with a `response` object of the form +`{data, error}`. + +Request payload: + +```js +{ + api: 'fromMiniApp', + widgetId: '', + requestId: 'any-unique-string', + action: 'io.tatuchat.storage.load', // one of the actions below + data: { /* action-specific payload */ } +} +``` + +Helper for your app (copy this into your HTML): + +```js +const APP_ID = 'myapp'; // must match the app id +const _pending = new Map(); +let _requestCounter = 0; + +function bridgeRequest(action, data) { + return new Promise(function (resolve, reject) { + const requestId = 'r' + (++_requestCounter); + _pending.set(requestId, { resolve: resolve, reject: reject }); + window.parent.postMessage({ + api: 'fromMiniApp', + widgetId: APP_ID, + requestId: requestId, + action: action, + data: data + }, '*'); + }); +} + +window.addEventListener('message', function (event) { + const msg = event.data; + if (!msg || msg.api !== 'fromMiniApp' || !msg.requestId) return; + if (!_pending.has(msg.requestId)) return; + const pending = _pending.get(msg.requestId); + _pending.delete(msg.requestId); + const response = msg.response || {}; + if (response.error) pending.reject(new Error(response.error)); + else pending.resolve(response.data); +}); +``` + +## Available actions + +### `io.tatuchat.storage.load` + +Loads the app's own key-value store (persisted in the user's account data under +`im.tatuchat.miniapps.data.`). Resolves with a plain object. + +```js +const data = await bridgeRequest('io.tatuchat.storage.load'); +// data => { ... } (any JSON values previously saved) +``` + +### `io.tatuchat.storage.save` + +Replaces the whole store. `data` must be an object. Resolves with `null`. + +```js +await bridgeRequest('io.tatuchat.storage.save', { tasks: [...], version: 1 }); +``` + +> Note: this **replaces** the entire store; there is no per-key merge. Load, +> mutate, then save. + +### `io.tatuchat.identity.get` + +Resolves with the signed-in user's profile: + +```js +const info = await bridgeRequest('io.tatuchat.identity.get'); +// => { +// userId: '@user:server', +// displayName: 'Tatu', +// avatarUrl: 'mxc://server/...', +// avatarHttpUrl: 'https://.../download/...', // may require auth +// avatarDataUri: 'data:image/png;base64,...', // always renderable +// homeserver: 'https://server.example' +// } +``` + +The `avatarDataUri` is a base64 data URI fetched by TatuChat with the user's +credentials. **Use `avatarDataUri` for ``** – the homeserver usually +requires an authenticated media request that a plain `` tag cannot send +(this is what caused the `M_MISSING_TOKEN` error). Fall back to `avatarHttpUrl` +only if `avatarDataUri` is absent. + +### `io.tatuchat.theme.get` + +Resolves with the color palette currently used by TatuChat, so your app can +match the ecosystem (light/dark aware). Colors are `#AARRGGBB` hex strings +suitable for CSS. + +```js +const { palette } = await bridgeRequest('io.tatuchat.theme.get'); +// => { +// brightness: 'light' | 'dark', +// primary: '#FF00D084', +// onPrimary: '#FF000000', +// primaryContainer: '#FF...', +// surface: '#FF...', +// surfaceContainerLowest/Low/Container/High/Highest: '#FF...', +// secondary..., tertiary..., error..., +// outline, outlineVariant, background, onBackground, onSurface, ... +// } +document.documentElement.style.setProperty('--primary', palette.primary); +``` + +### `io.tatuchat.accountdata.list` + +Resolves with a map of **all** account data of the signed-in user as an object +of `{ key: { ...content } }`. + +### `io.tatuchat.accountdata.get` + +`data: { key: 'org.example.something' }` resolves with `{ key, content }` where +`content` is the stored JSON content (or `{}` if absent). + +### `io.tatuchat.accountdata.set` + +`data: { key: 'org.example.something', content: { ... } }` stores or replaces +the content of that account data key. Resolves with `null`. + +## Caveats & limitations + +- **Distribution / installation:** apps imported from URL must be served with + permissive CORS headers (`Access-Control-Allow-Origin`) so the browser can + read them. On the web, TatuChat retries through a CORS proxy if you configure + one (setting `miniAppCorsProxy` in the app settings/config). If the server + blocks cross-origin reads, import the `.html` file manually instead. +- **Size:** the HTML source is stored in the user's account data, where + individual events are limited to about **64 KB** (homeservers like matrix.org + enforce this). Keep mini apps small and inline the assets; or load heavy + content from a URL at runtime. +- **Security:** the app runs in a sandboxed web view with `allow-scripts` / + `allow-forms` / `allow-same-origin`. It can only reach TatuChat through the + bridge actions above – it cannot access the user's rooms, messages or files. +- **No unsolicited access:** data is only exchanged when the app explicitly + calls the bridge. The host never pushes data into the app on its own. +- **Store is per app:** every app has its own `storage.load`/`save` namespace. + `accountdata.*` is the only way to share data between apps. +- **`storage.save` overwrites:** there is no merge/patch semantics. +- **Native platforms:** the HTML is rendered by the platform web view; take + care with features that differ between browsers (e.g. storage APIs inside the + sandbox, `window.parent` assumptions). Prefer the bridge over `localStorage`. \ No newline at end of file diff --git a/lib/config/miniapp_config.dart b/lib/config/miniapp_config.dart new file mode 100644 index 0000000..287b9b3 --- /dev/null +++ b/lib/config/miniapp_config.dart @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/utils/miniapp_store.dart'; + +/// ID of the built-in tasks example app. +const String miniAppTasksId = 'tasks'; + +/// ID of the built-in account data explorer app. +const String miniAppAccountDataId = 'accountdata'; + +/// Static registry of all built-in mini apps that ship with this build. A mini +/// app only shows up in the "Apps" page once the user installs it (see +/// [installedMiniApps]). Additionally to this list, users can import their own +/// single-file HTML apps which are resolved through [resolveMiniAppConfig]. +const List availableMiniApps = [ + MiniAppConfig( + id: miniAppTasksId, + icon: Icons.checklist_rounded, + color: Color(0xFF00D084), + htmlAsset: 'assets/miniapps/tasks.html', + ), + MiniAppConfig( + id: miniAppAccountDataId, + icon: Icons.storage_rounded, + color: Color(0xFF1F8EF1), + htmlAsset: 'assets/miniapps/accountdata.html', + ), +]; + +/// Declarative description of a mini app. The rendering is either a +/// self-contained HTML document loaded from [htmlAsset] (built-in apps) or +/// from [html] which is stored in the user's account data (imported apps). +class MiniAppConfig { + final String id; + final IconData icon; + final Color color; + final String? name; + final String? htmlAsset; + final String? html; + + const MiniAppConfig({ + required this.id, + required this.icon, + required this.color, + this.name, + this.htmlAsset, + this.html, + }); + + /// Config for a user-imported app whose HTML source is persisted in the + /// account data store. + MiniAppConfig.custom({ + required String id, + String? name, + String? html, + }) : this( + id: id, + icon: Icons.web_asset_rounded, + color: const Color(0xFF9C27B0), + name: name, + html: html, + ); + + bool get isCustom => html != null || htmlAsset == null; + + /// Resolves the HTML source that should be rendered for this app. Returns a + /// bundled asset path for built-in apps or the raw HTML for imported apps. + String? get htmlSource => html ?? htmlAsset; +} + +/// Resolves the config of a single installed app, whether it is built-in or +/// user-imported. Returns null if the app is not installed or not available. +MiniAppConfig? resolveMiniAppConfig(Client client, String appId) { + for (final app in availableMiniApps) { + if (app.id == appId) return app; + } + if (client.isMiniAppInstalled(appId)) { + final html = client.getMiniAppSource(appId); + if (html != null) { + return MiniAppConfig.custom( + id: appId, + name: client.getMiniAppName(appId), + html: html, + ); + } + } + return null; +} + +/// Returns the configs of all installed apps: built-in ones plus the +/// user-imported ones. The visual order is: installed built-in apps first, +/// then imported apps. +List installedMiniAppConfigs(Client client) { + final installed = client.installedMiniApps; + return [ + for (final app in availableMiniApps) + if (installed.contains(app.id)) app, + for (final id in installed) + if (!availableMiniApps.any((app) => app.id == id)) + if (client.getMiniAppSource(id) != null) + MiniAppConfig.custom( + id: id, + name: client.getMiniAppName(id), + html: client.getMiniAppSource(id), + ), + ]; +} + +/// Resolves the localized display name of a mini app configuration. +String miniAppName(BuildContext context, MiniAppConfig app) { + final customName = app.name; + if (customName != null && customName.isNotEmpty) return customName; + switch (app.id) { + case miniAppTasksId: + return L10n.of(context).tasks; + case miniAppAccountDataId: + return L10n.of(context).accountDataEditor; + default: + return app.id; + } +} \ No newline at end of file diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 079ecea..321664f 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'package:tatchat/config/themes.dart'; +import 'package:tatchat/pages/apps/apps.dart'; +import 'package:tatchat/pages/apps/miniapp_page.dart'; import 'package:tatchat/pages/archive/archive.dart'; import 'package:tatchat/pages/bootstrap/bootstrap_page.dart'; import 'package:tatchat/pages/chat/chat.dart'; @@ -241,6 +243,23 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, ), + GoRoute( + path: 'apps', + pageBuilder: (context, state) => + defaultPageBuilder(context, state, const Apps()), + routes: [ + GoRoute( + path: ':appId', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + MiniAppPage(appId: state.pathParameters['appId']!), + ), + redirect: loggedOutRedirect, + ), + ], + redirect: loggedOutRedirect, + ), ShellRoute( pageBuilder: (context, state, child) => defaultPageBuilder( context, diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 22bef06..20dd44e 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -6,12 +6,12 @@ import 'dart:convert'; import 'package:async/async.dart'; -import 'package:tatchat/utils/platform_infos.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:managed_configurations/managed_configurations.dart'; import 'package:matrix/matrix_api_lite/utils/logs.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tatchat/utils/platform_infos.dart'; enum AppSettings { textMessageMaxLength('textMessageMaxLength', 16384), @@ -87,7 +87,11 @@ enum AppSettings { benchmarksInLogs('chat.fluffy.benchmarks_in_logs', false), autoSendErrorReports('chat.fluffy.auto_send_eror_reports', null), knownErrorHashes>('chat.fluffy.known_crash_hashes', []), - customLiveKitInstance('chat.fluffy.custom_live_kit_instance', ''); + customLiveKitInstance('chat.fluffy.custom_live_kit_instance', ''), + /// URL of a CORS proxy used as a fallback when importing a mini app from a + /// URL on web, if the target server does not send CORS headers. Should end + /// with `?url=` or `?` (the target URL gets appended). Empty means disabled. + miniAppCorsProxy('chat.fluffy.miniapp_cors_proxy', ''); final String key; final T _defaultValue; diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 586271b..78726cd 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2359,6 +2359,7 @@ }, "appWantsToUseForLoginDescription": "You hereby allow the app and website to share information about you.", "open": "Open", + "refresh": "Refresh", "waitingForServer": "Waiting for server...", "newChatRequest": "📩 New chat request", "contentNotificationSettings": "Content notification settings", @@ -2923,5 +2924,75 @@ "areYouSureDeletePost": "Delete", "@areYouSureDeletePost": { "description": "Delete this post from the mural" + }, + "apps": "Apps", + "@apps": { + "type": "String", + "placeholders": {} + }, + "tasks": "Tasks", + "@tasks": { + "type": "String", + "placeholders": {} + }, + "accountDataEditor": "Account data", + "@accountDataEditor": { + "type": "String", + "placeholders": {} + }, + "addTask": "Add task", + "@addTask": { + "type": "String", + "placeholders": {} + }, + "taskInputPlaceholder": "What do you need to do?", + "@taskInputPlaceholder": { + "type": "String", + "placeholders": {} + }, + "noTasksYet": "No tasks yet", + "@noTasksYet": { + "type": "String", + "placeholders": {} + }, + "install": "Install", + "@install": { + "type": "String", + "placeholders": {} + }, + "uninstall": "Uninstall", + "@uninstall": { + "type": "String", + "placeholders": {} + }, + "installedApps": "Installed apps", + "@installedApps": { + "type": "String", + "placeholders": {} + }, + "availableApps": "Available apps", + "@availableApps": { + "type": "String", + "placeholders": {} + }, + "importApp": "Import app", + "@importApp": { + "type": "String", + "placeholders": {} + }, + "importFromFile": "Import from file", + "@importFromFile": { + "type": "String", + "placeholders": {} + }, + "importFromUrl": "Import from URL", + "@importFromUrl": { + "type": "String", + "placeholders": {} + }, + "appImported": "App imported", + "@appImported": { + "type": "String", + "placeholders": {} } } diff --git a/lib/pages/apps/apps.dart b/lib/pages/apps/apps.dart new file mode 100644 index 0000000..e722f61 --- /dev/null +++ b/lib/pages/apps/apps.dart @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/setting_keys.dart'; +import 'package:tatchat/utils/miniapp_store.dart'; +import 'package:tatchat/widgets/matrix.dart'; + +import 'apps_view.dart'; + +class Apps extends StatefulWidget { + const Apps({super.key}); + + @override + AppsController createState() => AppsController(); +} + +class AppsController extends State { + Client get client => Matrix.of(context).client; + + Future installMiniApp(String appId) async { + await client.installMiniApp(appId); + if (!mounted) return; + setState(() {}); + } + + Future uninstallMiniApp(String appId) async { + await client.uninstallMiniApp(appId); + if (!mounted) return; + setState(() {}); + } + + /// Lets the user pick a `.html` file from their device and imports it as a + /// custom mini app. + Future importMiniAppFromFile() async { + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: ['html', 'htm'], + ); + if (file == null) return; + final name = file.name.replaceAll(RegExp(r'\.(html?|htm)$'), ''); + final bytes = await file.readAsBytes(); + await _importHtml( + name: name.isEmpty ? 'Imported app' : name, + html: _decodeBytes(bytes), + ); + if (!mounted) return; + setState(() {}); + } + + /// Fetches an HTML document from [url] and imports it as a custom mini app. + /// + /// On web, the browser enforces CORS, so fetching a page that does not send + /// `Access-Control-Allow-Origin` fails with a `ClientException`. In that case + /// we transparently retry through a CORS proxy (if configured) so the import + /// still works. + Future importMiniAppFromUrl(String url) async { + if (url.trim().isEmpty) return; + final response = await _fetchViaCorsProxy(Uri.parse(url)); + if (response.statusCode != 200) { + throw Exception('${response.statusCode} ${response.reasonPhrase}'); + } + var name = Uri.parse(url).pathSegments.isNotEmpty + ? Uri.parse(url).pathSegments.last.replaceAll( + RegExp(r'\.(html?|htm)$'), + '', + ) + : url; + if (name.isEmpty) name = 'Imported app'; + await _importHtml(name: name, html: response.body); + if (!mounted) return; + setState(() {}); + } + + /// Fetches [url]. On web, if the initial request fails (usually because the + /// target server lacks CORS headers), retries through the configured CORS + /// proxy. Returns the successful response or rethrows the last error. + Future _fetchViaCorsProxy(Uri uri) async { + try { + final response = await http.get(uri); + if (response.statusCode == 200) return response; + // Non-200 (e.g. 404): fall through to the proxy only for CORS errors. + if (!kIsWeb) return response; + } catch (e) { + if (!kIsWeb) rethrow; + Logs().w('Direct download failed, trying CORS proxy', e); + } + final proxy = AppSettings.miniAppCorsProxy.value; + if (kIsWeb && proxy.isNotEmpty) { + final separator = proxy.contains('?') ? '&' : '?'; + final proxied = await http.get(Uri.parse('$proxy$separator$uri')); + if (proxied.statusCode == 200) return proxied; + throw Exception('${proxied.statusCode} ${proxied.reasonPhrase}'); + } + throw Exception( + 'The server does not allow cross-origin requests (CORS). ' + 'Import the file manually or set a CORS proxy in the settings.', + ); + } + + String _decodeBytes(List bytes) { + for (final encoding in const [utf8, latin1]) { + try { + return encoding.decode(bytes); + } catch (_) {} + } + return latin1.decode(bytes, allowInvalid: true); + } + + Future _importHtml({ + required String name, + required String html, + }) => + client.importMiniApp( + appId: 'imported-${DateTime.now().millisecondsSinceEpoch}', + name: name, + html: html, + ); + + @override + Widget build(BuildContext context) => AppsView(this); +} \ No newline at end of file diff --git a/lib/pages/apps/apps_view.dart b/lib/pages/apps/apps_view.dart new file mode 100644 index 0000000..0f0044c --- /dev/null +++ b/lib/pages/apps/apps_view.dart @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/miniapp_config.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/pages/apps/apps.dart'; +import 'package:tatchat/utils/miniapp_store.dart'; +import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; +import 'package:tatchat/widgets/layouts/max_width_body.dart'; + +class AppsView extends StatelessWidget { + final AppsController controller; + + const AppsView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final installed = controller.client.installedMiniApps; + final installedApps = installedMiniAppConfigs(controller.client); + final availableApps = availableMiniApps + .where((app) => !installed.contains(app.id)) + .toList(); + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + title: Text(L10n.of(context).apps), + actions: [ + PopupMenuButton<_ImportAction>( + icon: const Icon(Icons.add_box_outlined), + tooltip: L10n.of(context).importApp, + onSelected: (action) async { + switch (action) { + case _ImportAction.file: + await controller.importMiniAppFromFile(); + case _ImportAction.url: + final url = await showTextInputDialog( + context: context, + title: L10n.of(context).importFromUrl, + okLabel: L10n.of(context).importApp, + ); + if (url == null || url.trim().isEmpty) break; + try { + await controller.importMiniAppFromUrl(url); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context).appImported)), + ); + } catch (e, s) { + Logs().e('Failed to import mini app', e, s); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _ImportAction.file, + child: Row( + children: [ + const Icon(Icons.upload_file_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).importFromFile), + ], + ), + ), + PopupMenuItem( + value: _ImportAction.url, + child: Row( + children: [ + const Icon(Icons.link), + const SizedBox(width: 12), + Text(L10n.of(context).importFromUrl), + ], + ), + ), + ], + ), + ], + ), + body: MaxWidthBody( + withScrolling: false, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (installedApps.isNotEmpty) ...[ + _SectionHeader(L10n.of(context).installedApps), + for (final app in installedApps) + _AppCard( + app: app, + onTap: () => context.go('/rooms/apps/${app.id}'), + trailing: [ + IconButton( + icon: const Icon(Icons.open_in_new), + tooltip: L10n.of(context).open, + onPressed: () => context.go('/rooms/apps/${app.id}'), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: L10n.of(context).uninstall, + onPressed: () => controller.uninstallMiniApp(app.id), + ), + ], + ), + ], + if (availableApps.isNotEmpty) ...[ + _SectionHeader(L10n.of(context).availableApps), + for (final app in availableApps) + _AppCard( + app: app, + onTap: null, + trailing: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: TextButton( + onPressed: () => controller.installMiniApp(app.id), + child: Text(L10n.of(context).install), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} + +enum _ImportAction { + file, + url, +} + +class _SectionHeader extends StatelessWidget { + final String text; + + const _SectionHeader(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Text(text, style: Theme.of(context).textTheme.titleSmall), + ); + } +} + +class _AppCard extends StatelessWidget { + final MiniAppConfig app; + final VoidCallback? onTap; + final List trailing; + + const _AppCard({required this.app, required this.trailing, this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + onTap: onTap, + leading: CircleAvatar( + backgroundColor: app.color.withValues(alpha: .15), + foregroundColor: app.color, + child: Icon(app.icon), + ), + title: Text(miniAppName(context, app)), + trailing: Row(mainAxisSize: MainAxisSize.min, children: trailing), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/apps/miniapp_page.dart b/lib/pages/apps/miniapp_page.dart new file mode 100644 index 0000000..2b32309 --- /dev/null +++ b/lib/pages/apps/miniapp_page.dart @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 TatuChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/miniapp_config.dart'; +import 'package:tatchat/utils/miniapp_bridge.dart'; +import 'package:tatchat/widgets/matrix.dart'; + +import 'miniapp_shell_stub.dart' + if (dart.library.js_interop) 'miniapp_shell_web.dart' + if (dart.library.ffi) 'miniapp_shell_native.dart'; + +/// Generic page that renders a mini app. The mini app is a self-contained HTML +/// document that talks to the TatuChat client through the [MiniappBridge] +/// protocol (see `assets/miniapps/`). +class MiniAppPage extends StatefulWidget { + final String appId; + + const MiniAppPage({super.key, required this.appId}); + + @override + State createState() => _MiniAppPageState(); +} + +class _MiniAppPageState extends State { + Client get client => Matrix.of(context).client; + + MiniAppConfig? _config; + String? _html; + bool _notFound = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final config = resolveMiniAppConfig(client, widget.appId); + if (config == null) { + if (!mounted) return; + setState(() => _notFound = true); + return; + } + if (config.htmlAsset != null) { + final html = await rootBundle.loadString(config.htmlAsset!); + if (!mounted) return; + setState(() { + _config = config; + _html = html; + }); + return; + } + // Imported app: the HTML source lives in the account data store. + if (!mounted) return; + setState(() { + _config = config; + _html = config.html; + }); + } + + /// Entry point of the bridge: called by the web view/iframe with a request + /// coming from the mini app's JavaScript. + Future> handleRequest(Map request) { + final scheme = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + return MiniappBridge.handleRequest( + client, + widget.appId, + request, + palette: MiniappBridge.buildPalette(scheme, brightness), + ); + } + + @override + Widget build(BuildContext context) { + if (_notFound) { + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + title: Text(widget.appId), + ), + body: const Center(child: Text('App not found')), + ); + } + final html = _html; + final config = _config; + if (html == null || config == null) { + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + title: Text( + _config?.name ?? _config?.id ?? widget.appId, + ), + ), + body: const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + return MiniAppShell( + key: ValueKey(widget.appId), + html: html, + appId: widget.appId, + title: config.name ?? config.id, + onRequest: handleRequest, + ); + } +} diff --git a/lib/pages/apps/miniapp_shell_native.dart b/lib/pages/apps/miniapp_shell_native.dart new file mode 100644 index 0000000..4b436b3 --- /dev/null +++ b/lib/pages/apps/miniapp_shell_native.dart @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 TatuChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:tatchat/l10n/l10n.dart'; + +/// Native implementation of [MiniAppShell] using an isolated [InAppWebView]. +/// +/// The mini app HTML is shipped with a small bridge shim injected before +/// `` that forwards `window.parent.postMessage` calls to Dart through +/// `flutter_inappwebview`'s JavaScript handler, and delivers the response +/// back to the mini app via a `postMessage` with the same `requestId`. +class MiniAppShell extends StatefulWidget { + final String html; + final String appId; + final String title; + final Future> Function(Map request) + onRequest; + + const MiniAppShell({ + super.key, + required this.html, + required this.appId, + required this.title, + required this.onRequest, + }); + + @override + State createState() => _MiniAppShellState(); +} + +class _MiniAppShellState extends State { + static const String _handlerName = 'com.tatuchat.miniapp.bridge'; + + InAppWebViewController? _webViewController; + + String get _injectedHtml { + const shim = + ''' + +'''; + final bodyEnd = widget.html.lastIndexOf(''); + if (bodyEnd == -1) { + return '${widget.html}\n$shim'; + } + return widget.html.replaceRange(bodyEnd, bodyEnd, '\n$shim'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + actions: [ + IconButton( + tooltip: L10n.of(context).refresh, + icon: const Icon(Icons.refresh_rounded), + onPressed: () => _webViewController?.reload(), + ), + ], + ), + backgroundColor: theme.colorScheme.surface, + body: InAppWebView( + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + javaScriptCanOpenWindowsAutomatically: false, + domStorageEnabled: true, + transparentBackground: false, + verticalScrollBarEnabled: true, + horizontalScrollBarEnabled: false, + ), + initialData: InAppWebViewInitialData( + data: _injectedHtml, + mimeType: 'text/html', + encoding: 'utf8', + baseUrl: WebUri('https://tatuchat.local'), + ), + onWebViewCreated: (controller) { + _webViewController = controller; + controller.addJavaScriptHandler( + handlerName: _handlerName, + callback: (args) async { + if (args.isEmpty) return null; + final raw = args.first; + if (raw is Map) { + return widget.onRequest(Map.from(raw)); + } + return null; + }, + ); + }, + ), + ); + } +} diff --git a/lib/pages/apps/miniapp_shell_stub.dart b/lib/pages/apps/miniapp_shell_stub.dart new file mode 100644 index 0000000..e7e95fe --- /dev/null +++ b/lib/pages/apps/miniapp_shell_stub.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 TatuChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/widgets.dart'; + +/// Fallback used when neither `dart.library.js_interop` nor `dart.library.ffi` +/// is available. It should never be selected on a supported platform. +class MiniAppShell extends StatelessWidget { + final String html; + final String appId; + final String title; + final Future> Function(Map request) + onRequest; + + const MiniAppShell({ + super.key, + required this.html, + required this.appId, + required this.title, + required this.onRequest, + }); + + @override + Widget build(BuildContext context) => throw UnsupportedError( + 'MiniAppShell is not implemented for this platform', + ); +} diff --git a/lib/pages/apps/miniapp_shell_web.dart b/lib/pages/apps/miniapp_shell_web.dart new file mode 100644 index 0000000..a806baf --- /dev/null +++ b/lib/pages/apps/miniapp_shell_web.dart @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 TatuChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'dart:js_interop'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/material.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/utils/miniapp_bridge.dart'; +import 'package:web/web.dart' as web; + +/// Web implementation of [MiniAppShell] rendering the mini app into a sandboxed +/// iframe. +/// +/// The mini app talks to TatuChat through `postMessage`. This widget listens on +/// `window` for `fromMiniApp` requests, forwards them to the Dart bridge and +/// posts the response back to the iframe's `contentWindow`. +class MiniAppShell extends StatefulWidget { + final String html; + final String appId; + final String title; + final Future> Function(Map request) + onRequest; + + const MiniAppShell({ + super.key, + required this.html, + required this.appId, + required this.title, + required this.onRequest, + }); + + @override + State createState() => _MiniAppShellState(); +} + +class _MiniAppShellState extends State { + static int _viewTypeCounter = 0; + late final String _viewType; + web.HTMLIFrameElement? _iframe; + web.HTMLDivElement? _frameElement; + JSFunction? _messageListener; + + @override + void initState() { + super.initState(); + _viewType = 'tatchat-miniapp-${_viewTypeCounter++}'; + ui_web.platformViewRegistry.registerViewFactory( + _viewType, + (int viewId) => _createElement(), + ); + final messageListener = _onMessage.toJS; + _messageListener = messageListener; + web.window.addEventListener('message', messageListener); + } + + @override + void dispose() { + final listener = _messageListener; + if (listener != null) { + web.window.removeEventListener('message', listener); + } + super.dispose(); + } + + web.HTMLIFrameElement _buildIframe() { + final iframe = + web.document.createElement('iframe') as web.HTMLIFrameElement; + iframe.style + ..width = '100%' + ..height = '100%' + ..border = 'none'; + iframe.setAttribute( + 'sandbox', + 'allow-scripts allow-forms allow-same-origin', + ); + iframe.setAttribute('srcdoc', widget.html); + return iframe; + } + + web.HTMLElement _createElement() { + final div = web.document.createElement('div') as web.HTMLDivElement; + div.style + ..width = '100%' + ..height = '100%' + ..margin = '0' + ..padding = '0'; + final iframe = _buildIframe(); + div.append(iframe); + _iframe = iframe; + _frameElement = div; + return div; + } + + void _reload() { + final div = _frameElement; + if (div == null) return; + _iframe?.remove(); + final iframe = _buildIframe(); + div.append(iframe); + _iframe = iframe; + } + + void _onMessage(web.Event event) { + if (!event.isA()) return; + final messageEvent = event as web.MessageEvent; + final data = messageEvent.data?.dartify(); + if (data is! Map) return; + final request = Map.from(data); + if (request['api'] != MiniappBridge.api) return; + final iframeWindow = _iframe?.contentWindow; + if (iframeWindow == null) return; + final requestId = request['requestId']; + final widgetId = request['widgetId']; + widget.onRequest(request).then((result) { + iframeWindow.postMessage( + { + 'api': MiniappBridge.api, + 'widgetId': widgetId, + 'requestId': requestId, + 'response': result, + }.jsify(), + '*'.toJS, + ); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + actions: [ + IconButton( + tooltip: L10n.of(context).refresh, + icon: const Icon(Icons.refresh_rounded), + onPressed: _reload, + ), + ], + ), + backgroundColor: theme.colorScheme.surface, + body: HtmlElementView(viewType: _viewType), + ); + } +} diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 10a2df4..1fdc60d 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -71,6 +71,16 @@ class ClientChooserButton extends StatelessWidget { ], ), ), + PopupMenuItem( + value: SettingsAction.apps, + child: Row( + children: [ + const Icon(Icons.apps_outlined), + const SizedBox(width: 18), + Text(L10n.of(context).apps), + ], + ), + ), PopupMenuItem( value: SettingsAction.settings, child: Row( @@ -222,6 +232,9 @@ class ClientChooserButton extends StatelessWidget { case SettingsAction.archive: context.go('/rooms/archive'); break; + case SettingsAction.apps: + context.go('/rooms/apps'); + break; case SettingsAction.setStatus: controller.setStatus(); break; @@ -238,4 +251,5 @@ enum SettingsAction { support, settings, archive, + apps, } diff --git a/lib/utils/miniapp_bridge.dart b/lib/utils/miniapp_bridge.dart new file mode 100644 index 0000000..22361e4 --- /dev/null +++ b/lib/utils/miniapp_bridge.dart @@ -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": "", +/// "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 + /// `#AARRGGBB` hex strings so they can be used directly in CSS/JS. + static Map 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> _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}; + } +} \ No newline at end of file diff --git a/lib/utils/miniapp_store.dart b/lib/utils/miniapp_store.dart new file mode 100644 index 0000000..8db3cb7 --- /dev/null +++ b/lib/utils/miniapp_store.dart @@ -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 get installedMiniApps { + final apps = accountData[installedMiniAppsKey]?.content['apps']; + if (apps is List) return [...apps.whereType()]; + return []; + } + + bool isMiniAppInstalled(String appId) => installedMiniApps.contains(appId); + + Future installMiniApp(String appId) async { + final apps = installedMiniApps; + if (apps.contains(appId)) return; + apps.add(appId); + await setAccountData(userID!, installedMiniAppsKey, {'apps': apps}); + } + + Future 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 getMiniAppData(String appId) => + accountData['$miniAppDataKeyPrefix$appId']?.content ?? const {}; + + /// Overwrites the JSON document of the given mini app. + Future setMiniAppData(String appId, Map 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 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; + } +} diff --git a/pubspec.lock b/pubspec.lock index e2dc559..b35543d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -587,6 +587,70 @@ packages: url: "https://pub.dev" source: hosted version: "11.0.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 94716d0..d667f11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: sdk: flutter flutter_callkit_incoming: ^3.1.5 flutter_foreground_task: ^11.0.1 + flutter_inappwebview: ^6.1.5 flutter_linkify: ^6.0.0 flutter_local_notifications: ^22.3.0 flutter_localizations: @@ -118,6 +119,7 @@ flutter: assets: - assets/ - assets/logo/mini/ + - assets/miniapps/ - assets/sounds/ - assets/vodozemac/0.8.1/