// 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), ); } }