blob: c614501e53289e5a57df3ac513310ca6314e8a59 [file]
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();
},
);
}