| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:developer' as developer; |
| import 'dart:js_interop'; |
| import 'dart:js_interop_unsafe'; |
| import 'dart:ui_web' as ui_web; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import '{{entrypoint}}' as entrypoint; |
| |
| @JS('window') |
| external JSObject get _window; |
| |
| @JS('console.error') |
| external void _consoleError(JSString message); |
| |
| class SandboxAssetBundle extends AssetBundle { |
| static final Map<String, Completer<ByteData>> _pendingRequests = {}; |
| static int _nextRequestId = 0; |
| static bool _extensionRegistered = false; |
| |
| static void _ensureExtensionRegistered() { |
| if (_extensionRegistered) return; |
| _extensionRegistered = true; |
| developer.registerExtension('ext.vibepad.setAsset', (String method, Map<String, String> parameters) async { |
| final id = parameters['id']; |
| final base64Data = parameters['data']; |
| final error = parameters['error']; |
| final completer = _pendingRequests.remove(id); |
| if (completer != null) { |
| if (error != null) { |
| completer.completeError(Exception(error)); |
| } else if (base64Data != null) { |
| try { |
| final bytes = base64Decode(base64Data); |
| completer.complete(ByteData.sublistView(bytes)); |
| } catch (e) { |
| completer.completeError(e); |
| } |
| } else { |
| completer.completeError(Exception('No data or error provided')); |
| } |
| } |
| return developer.ServiceExtensionResponse.result(jsonEncode({'success': true})); |
| }); |
| } |
| |
| @override |
| Future<ByteData> load(String key) async { |
| _ensureExtensionRegistered(); |
| final requestId = 'asset_${_nextRequestId++}'; |
| final completer = Completer<ByteData>(); |
| _pendingRequests[requestId] = completer; |
| |
| developer.postEvent('vibepad.readAsset', { |
| 'key': key, |
| 'id': requestId, |
| }); |
| |
| try { |
| return await completer.future.timeout(const Duration(seconds: 10)); |
| } catch (e) { |
| _pendingRequests.remove(requestId); |
| return rootBundle.load(key); |
| } |
| } |
| } |
| |
| class SandboxWidgetsBinding extends WidgetsFlutterBinding { |
| @override |
| Widget wrapWithDefaultView(Widget rootWidget) { |
| return super.wrapWithDefaultView( |
| DefaultAssetBundle( |
| bundle: SandboxAssetBundle(), |
| child: rootWidget, |
| ), |
| ); |
| } |
| } |
| |
| Future<void> main() async { |
| // Disable URL strategy to prevent SecurityError in srcdoc iframes |
| ui_web.urlStrategy = null; |
| |
| // Capture errors and pipe to console.error |
| FlutterError.onError = (details) { |
| _consoleError(details.toString().toJS); |
| }; |
| |
| // Mock DWDS indicators to allow Flutter to register hot reload 'reassemble' |
| // extension. |
| _window[r'$dwdsVersion'] = true.toJS; |
| _window[r'$emitRegisterEvent'] = ((String _) {}).toJS; |
| |
| // Register Vibepad Agent extensions |
| developer.registerExtension('ext.vibepad.findWidgets', (String method, Map<String, String> parameters) async { |
| final queryText = parameters['text']; |
| final queryType = parameters['type']; |
| final rootId = parameters['rootNodeId']; |
| |
| final results = <Map<String, dynamic>>[]; |
| Element? root; |
| if (rootId != null) { |
| final object = WidgetInspectorService.instance.toObject(rootId); |
| if (object is Element) { |
| root = object; |
| } |
| } else { |
| root = WidgetsBinding.instance.rootElement; |
| } |
| |
| if (root != null) { |
| String? getElementText(Element element) { |
| final w = element.widget; |
| if (w is Text) return w.data; |
| if (w is EditableText) return w.controller.text; |
| if (w is RichText) return w.text.toPlainText(); |
| return null; |
| } |
| |
| bool hasTextInSubtree(Element element, String textQuery) { |
| final text = getElementText(element); |
| if (text != null && text.contains(textQuery)) { |
| return true; |
| } |
| bool found = false; |
| element.visitChildren((child) { |
| if (!found && hasTextInSubtree(child, textQuery)) { |
| found = true; |
| } |
| }); |
| return found; |
| } |
| |
| void visitor(Element element) { |
| bool isMatch = false; |
| if (queryText != null && queryType != null) { |
| final typeStr = element.widget.runtimeType.toString(); |
| if (typeStr == queryType && hasTextInSubtree(element, queryText)) { |
| isMatch = true; |
| } |
| } else if (queryText != null) { |
| final text = getElementText(element); |
| if (text != null && text.contains(queryText)) { |
| isMatch = true; |
| } |
| } else if (queryType != null) { |
| final typeStr = element.widget.runtimeType.toString(); |
| if (typeStr == queryType) { |
| isMatch = true; |
| } |
| } |
| |
| if (isMatch) { |
| final id = WidgetInspectorService.instance.toId(element, 'vibepad_agent_inspector'); |
| results.add({ |
| 'id': id, |
| 'widget': element.widget.runtimeType.toString(), |
| }); |
| } |
| element.visitChildren(visitor); |
| } |
| |
| visitor(root); |
| } |
| |
| return developer.ServiceExtensionResponse.result( |
| jsonEncode({ |
| 'success': true, |
| 'matches': results, |
| }), |
| ); |
| }); |
| |
| developer.registerExtension('ext.vibepad.tapWidget', (String method, Map<String, String> parameters) async { |
| final id = parameters['id']; |
| if (id == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Missing "id" parameter', |
| ); |
| } |
| |
| final object = WidgetInspectorService.instance.toObject(id); |
| if (object == null || object is! Element) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Widget not found for id: $id', |
| ); |
| } |
| |
| final renderObject = object.renderObject; |
| if (renderObject is! RenderBox) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Widget does not have a RenderBox', |
| ); |
| } |
| |
| final size = renderObject.size; |
| final globalOffset = renderObject.localToGlobal(Offset.zero); |
| final center = globalOffset + Offset(size.width / 2, size.height / 2); |
| |
| final pointerId = GestureBinding.instance.pointerRouter.hashCode; |
| GestureBinding.instance.handlePointerEvent( |
| PointerDownEvent( |
| pointer: pointerId, |
| position: center, |
| ), |
| ); |
| |
| await Future<void>.delayed(const Duration(milliseconds: 50)); |
| |
| GestureBinding.instance.handlePointerEvent( |
| PointerUpEvent( |
| pointer: pointerId, |
| position: center, |
| ), |
| ); |
| |
| return developer.ServiceExtensionResponse.result( |
| jsonEncode({ |
| 'success': true, |
| }), |
| ); |
| }); |
| |
| developer.registerExtension('ext.vibepad.enterText', (String method, Map<String, String> parameters) async { |
| final id = parameters['id']; |
| final text = parameters['text']; |
| if (id == null || text == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Missing "id" or "text" parameter', |
| ); |
| } |
| |
| final object = WidgetInspectorService.instance.toObject(id); |
| if (object == null || object is! Element) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Widget not found or is not an Element for id: $id', |
| ); |
| } |
| |
| EditableTextState? editableTextState; |
| void finder(Element element) { |
| if (element is StatefulElement && element.state is EditableTextState) { |
| editableTextState = element.state as EditableTextState; |
| return; |
| } |
| element.visitChildren(finder); |
| } |
| |
| finder(object); |
| |
| if (editableTextState == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'No EditableText found in the subtree of the target widget', |
| ); |
| } |
| |
| editableTextState!.widget.controller.value = TextEditingValue( |
| text: text, |
| selection: TextSelection.collapsed(offset: text.length), |
| ); |
| editableTextState!.widget.focusNode.requestFocus(); |
| |
| return developer.ServiceExtensionResponse.result( |
| jsonEncode({ |
| 'success': true, |
| }), |
| ); |
| }); |
| |
| developer.registerExtension('ext.vibepad.scroll', (String method, Map<String, String> parameters) async { |
| final id = parameters['id']; |
| if (id == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Missing "id" parameter', |
| ); |
| } |
| |
| final object = WidgetInspectorService.instance.toObject(id); |
| if (object == null || object is! Element) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Widget not found for id: $id', |
| ); |
| } |
| |
| final ensureVisible = parameters['ensureVisible'] == 'true'; |
| if (ensureVisible) { |
| await Scrollable.ensureVisible( |
| object, |
| alignment: 0.5, |
| duration: const Duration(milliseconds: 300), |
| curve: Curves.easeInOut, |
| ); |
| return developer.ServiceExtensionResponse.result( |
| jsonEncode({ |
| 'success': true, |
| }), |
| ); |
| } |
| |
| final deltaStr = parameters['delta']; |
| if (deltaStr == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'Missing "delta" parameter when ensureVisible is false', |
| ); |
| } |
| |
| final delta = double.tryParse(deltaStr) ?? 0.0; |
| |
| ScrollableState? scrollable = Scrollable.maybeOf(object); |
| if (scrollable == null) { |
| ScrollableState? childScrollable; |
| void findScrollable(Element el) { |
| if (childScrollable != null) return; |
| if (el is StatefulElement && el.state is ScrollableState) { |
| childScrollable = el.state as ScrollableState; |
| return; |
| } |
| el.visitChildren(findScrollable); |
| } |
| |
| findScrollable(object); |
| scrollable = childScrollable; |
| } |
| |
| if (scrollable == null) { |
| return developer.ServiceExtensionResponse.error( |
| developer.ServiceExtensionResponse.invalidParams, |
| 'No scrollable widget found for the target widget', |
| ); |
| } |
| |
| final position = scrollable.position; |
| final targetPixels = position.pixels + delta; |
| await position.animateTo( |
| targetPixels.clamp(position.minScrollExtent, position.maxScrollExtent), |
| duration: const Duration(milliseconds: 300), |
| curve: Curves.easeInOut, |
| ); |
| |
| return developer.ServiceExtensionResponse.result( |
| jsonEncode({ |
| 'success': true, |
| }), |
| ); |
| }); |
| |
| await ui_web.bootstrapEngine( |
| runApp: () { |
| // Initialize our custom widgets binding to override asset loading |
| SandboxWidgetsBinding(); |
| |
| entrypoint.main(); |
| }, |
| registerPlugins: () { |
| // pluginRegistrant.registerPlugins(); |
| }, |
| ); |
| } |