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