| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:js_interop'; |
| import 'dart:typed_data'; |
| |
| import 'package:crypto/crypto.dart' show md5; |
| import 'package:jaspr/jaspr.dart'; |
| import 'package:logging/logging.dart'; |
| import 'package:web/web.dart' as web; |
| |
| import '../../../utils/provider.dart'; |
| import '../../bottompanel/view_models/diagnostics_view_model.dart'; |
| import '../../shared/components/icons.dart' show shareIcon; |
| import '../../shared/components/text_button.dart'; |
| import '../../shared/logic/app_event_bus.dart'; |
| import '../../workspace/data/workspace_repository.dart'; |
| import '../view_models/preview_view_model.dart'; |
| import 'share_dialog.dart'; |
| |
| @Import.onWeb('package:firebase_storage/firebase_storage.dart', show: [#FirebaseStorage, #SettableMetadata]) |
| import 'share_button.imports.dart'; |
| |
| class ShareButton extends StatefulComponent { |
| const ShareButton({this.disabled = false, super.key}); |
| |
| final bool disabled; |
| |
| @override |
| State<ShareButton> createState() => _ShareButtonState(); |
| } |
| |
| class _ShareButtonState extends State<ShareButton> { |
| bool _running = false; |
| late AppCommandBus commandBus; |
| String? _shareUrl; |
| |
| @override |
| void initState() { |
| super.initState(); |
| commandBus = context.get<AppCommandBus>(); |
| } |
| |
| Future<bool> _runPreflightChecks() async { |
| final workspaceRepository = context.get<WorkspaceRepository>(); |
| final diagnosticsViewModel = context.get<DiagnosticsViewModel>(); |
| |
| final validation = await workspaceRepository.validateForPreview( |
| diagnosticsViewModel.allDiagnostics, |
| ); |
| if (!validation.isValid) { |
| final previewViewModel = context.get<PreviewViewModel>(); |
| previewViewModel.showValidationError(validation); |
| final commandBus = context.get<AppCommandBus>(); |
| commandBus.dispatch(const TogglePanelCommand(PanelType.editor, visible: true)); |
| commandBus.dispatch(const TogglePanelCommand(PanelType.console, visible: true)); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| Future<void> _compileToWasmAction() async { |
| final workspaceRepository = context.get<WorkspaceRepository>(); |
| |
| setState(() { |
| _running = true; |
| }); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Running WASM compilation...')); |
| |
| try { |
| final result = await workspaceRepository.compileToWasm('lib/main.dart'); |
| commandBus.dispatch(LogConsoleMessageCommand(result.log)); |
| |
| commandBus.dispatch( |
| const LogConsoleMessageCommand('[WASM] Compilation successful. Fetching custom flutter.js...'), |
| ); |
| |
| // Fetch VibePad's custom flutter.js from local assets |
| final response = await web.window.fetch('flutter/flutter.js'.toJS).toDart; |
| final flutterJsText = (await response.text().toDart).toDart; |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Fetching CanvasKit and Skwasm assets...')); |
| |
| final canvaskitFiles = [ |
| 'canvaskit.js', |
| 'canvaskit.wasm', |
| 'chromium/canvaskit.js', |
| 'chromium/canvaskit.wasm', |
| 'skwasm.js', |
| 'skwasm.wasm', |
| 'skwasm_heavy.js', |
| 'skwasm_heavy.wasm', |
| 'webparagraph/canvaskit.js', |
| 'webparagraph/canvaskit.wasm', |
| 'wimp.js', |
| 'wimp.wasm', |
| ]; |
| |
| final fetchFutures = canvaskitFiles.map((file) async { |
| final url = 'flutter/canvaskit/$file'; |
| final res = await web.window.fetch(url.toJS).toDart; |
| if (!res.ok) { |
| throw Exception('Failed to fetch $url'); |
| } |
| final arrayBuffer = await res.arrayBuffer().toDart; |
| final bytes = arrayBuffer.toDart.asUint8List(); |
| return (path: 'canvaskit/$file', bytes: bytes); |
| }); |
| |
| final fetchedAssets = await Future.wait(fetchFutures); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Fetching standard engine assets...')); |
| |
| final standardAssets = [ |
| 'FontManifest.json', |
| 'NOTICES', |
| 'fonts/MaterialIcons-Regular.otf', |
| 'shaders/ink_sparkle.frag', |
| 'shaders/stretch_effect.frag', |
| ]; |
| |
| final standardAssetFetchFutures = standardAssets.map((file) async { |
| final url = 'flutter/assets/$file'; |
| final res = await web.window.fetch(url.toJS).toDart; |
| if (!res.ok) { |
| throw Exception('Failed to fetch $url'); |
| } |
| final arrayBuffer = await res.arrayBuffer().toDart; |
| final bytes = arrayBuffer.toDart.asUint8List(); |
| return (path: 'assets/$file', bytes: bytes); |
| }); |
| |
| final fetchedStandardAssets = await Future.wait(standardAssetFetchFutures); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Searching workspace for declared assets...')); |
| |
| final workspace = workspaceRepository.workspace; |
| final foundWorkspaceFiles = <String>{}; |
| |
| String? pubspecText; |
| try { |
| pubspecText = await workspace.readFileAsText('pubspec.yaml'); |
| } catch (e) { |
| // Ignored |
| } |
| |
| if (pubspecText != null) { |
| final declaredAssets = _parseAssetsFromPubspec(pubspecText); |
| for (final assetPath in declaredAssets) { |
| try { |
| if (await workspace.fileExist(assetPath)) { |
| foundWorkspaceFiles.add(assetPath); |
| } else { |
| // Look for files in this folder (or prefix matching folder) |
| final entries = await workspace.listDirectory(uri: assetPath, recursive: true); |
| for (final entry in entries) { |
| if (entry.type == 'file') { |
| final cleanFolder = assetPath.endsWith('/') ? assetPath : '$assetPath/'; |
| foundWorkspaceFiles.add('$cleanFolder${entry.path}'); |
| } |
| } |
| } |
| } catch (e) { |
| commandBus.dispatch(LogConsoleMessageCommand('[WASM] Warning: Failed to resolve asset "$assetPath": $e')); |
| } |
| } |
| } |
| |
| final filesToUpload = <({String path, Uint8List bytes})>[]; |
| |
| // Add main.wasm |
| final wasmBytes = base64.decode(result.wasm); |
| filesToUpload.add((path: 'main.wasm', bytes: wasmBytes)); |
| |
| // Add main.js |
| final jsBytes = Uint8List.fromList(utf8.encode(result.js)); |
| filesToUpload.add((path: 'main.js', bytes: jsBytes)); |
| |
| // Add flutter.js |
| final flutterJsBytes = Uint8List.fromList(utf8.encode(flutterJsText)); |
| filesToUpload.add((path: 'flutter.js', bytes: flutterJsBytes)); |
| |
| // Add canvaskit/skwasm assets |
| for (final asset in fetchedAssets) { |
| filesToUpload.add((path: asset.path, bytes: asset.bytes)); |
| } |
| |
| // Add standard engine assets |
| for (final asset in fetchedStandardAssets) { |
| filesToUpload.add((path: asset.path, bytes: asset.bytes)); |
| } |
| |
| // Copy and add workspace assets |
| final manifestMap = <String, List<Map<String, Object?>>>{}; |
| final manifestMapForJson = <String, List<String>>{}; |
| |
| for (final file in foundWorkspaceFiles) { |
| try { |
| final fileBytes = await workspace.readFileAsBytes(file); |
| filesToUpload.add((path: 'assets/$file', bytes: fileBytes)); |
| |
| final variant = _parseAssetVariant(file); |
| final entry = <String, Object?>{ |
| 'asset': file, |
| }; |
| if (variant.dpr != null) { |
| entry['dpr'] = variant.dpr; |
| } |
| manifestMap.putIfAbsent(variant.mainKey, () => []).add(entry); |
| } catch (e) { |
| commandBus.dispatch(LogConsoleMessageCommand('[WASM] Warning: Failed to bundle asset "$file": $e')); |
| } |
| } |
| |
| // Regenerate AssetManifest files |
| manifestMap.forEach((key, list) { |
| manifestMapForJson[key] = list.map((item) => item['asset']! as String).toList(); |
| }); |
| |
| final encoder = SimpleStandardMessageEncoder(); |
| encoder.writeValue(manifestMap); |
| final manifestBinBytes = encoder.bytes; |
| |
| filesToUpload.add((path: 'assets/AssetManifest.bin', bytes: manifestBinBytes)); |
| filesToUpload.add(( |
| path: 'assets/AssetManifest.bin.json', |
| bytes: Uint8List.fromList(utf8.encode(json.encode(base64.encode(manifestBinBytes)))), |
| )); |
| filesToUpload.add(( |
| path: 'assets/AssetManifest.json', |
| bytes: Uint8List.fromList(utf8.encode(json.encode(manifestMapForJson))), |
| )); |
| |
| // Fetch index.html template from local server (served from web/share/app.html) |
| final htmlResponse = await web.window.fetch('share/app.html'.toJS).toDart; |
| final htmlContent = (await htmlResponse.text().toDart).toDart; |
| final htmlBytes = Uint8List.fromList(utf8.encode(htmlContent)); |
| filesToUpload.add((path: 'index.html', bytes: htmlBytes)); |
| |
| final hash = md5.convert([...wasmBytes, ...htmlBytes]).toString(); |
| |
| final storage = FirebaseStorage.instance; |
| final uploadFutures = filesToUpload.map((file) async { |
| final ref = storage.ref('share/$hash/${file.path}'); |
| final mimeType = _getMimeType(file.path); |
| await ref.putData(file.bytes, SettableMetadata(contentType: mimeType)); |
| }); |
| |
| await Future.wait(uploadFutures); |
| |
| final shareUrl = '${web.document.baseURI}share.html?id=$hash'; |
| |
| setState(() { |
| _shareUrl = shareUrl; |
| }); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Share bundle ready!')); |
| } catch (e, st) { |
| web.console.error('Error while preparing WASM build:'.toJS); |
| web.console.error(e.toString().toJS); |
| web.console.error(st.toString().toJS); |
| commandBus.dispatch( |
| LogConsoleMessageCommand( |
| '[WASM] Error while preparing WASM build', |
| level: Level.SEVERE, |
| error: e, |
| stackTrace: st, |
| ), |
| ); |
| } finally { |
| setState(() => _running = false); |
| } |
| } |
| |
| String _getMimeType(String path) { |
| final ext = path.split('.').last.toLowerCase(); |
| return switch (ext) { |
| 'html' => 'text/html', |
| 'js' => 'application/javascript', |
| 'wasm' => 'application/wasm', |
| 'json' => 'application/json', |
| 'otf' => 'font/otf', |
| 'ttf' => 'font/ttf', |
| 'frag' => 'text/plain', |
| 'png' => 'image/png', |
| 'jpg' || 'jpeg' => 'image/jpeg', |
| 'gif' => 'image/gif', |
| 'svg' => 'image/svg+xml', |
| _ => 'application/octet-stream', |
| }; |
| } |
| |
| List<String> _parseAssetsFromPubspec(String pubspec) { |
| final assets = <String>[]; |
| final lines = pubspec.split('\n'); |
| bool inFlutter = false; |
| bool inAssets = false; |
| |
| for (var line in lines) { |
| final trimmed = line.trim(); |
| if (trimmed.startsWith('#')) { |
| continue; |
| } |
| |
| if (line.startsWith('flutter:')) { |
| inFlutter = true; |
| inAssets = false; |
| continue; |
| } else if (line.isNotEmpty && !line.startsWith(' ') && !line.startsWith('\t')) { |
| inFlutter = false; |
| inAssets = false; |
| } |
| |
| if (inFlutter) { |
| if (trimmed == 'assets:') { |
| inAssets = true; |
| continue; |
| } else if (line.isNotEmpty && !line.startsWith(' ') && !line.startsWith('\t ')) { |
| inAssets = false; |
| } |
| |
| if (inAssets && trimmed.startsWith('-')) { |
| var assetPath = trimmed.substring(1).trim(); |
| if ((assetPath.startsWith("'") && assetPath.endsWith("'")) || |
| (assetPath.startsWith('"') && assetPath.endsWith('"'))) { |
| assetPath = assetPath.substring(1, assetPath.length - 1); |
| } |
| if (assetPath.isNotEmpty) { |
| assets.add(assetPath); |
| } |
| } |
| } |
| } |
| return assets; |
| } |
| |
| ({String mainKey, double? dpr}) _parseAssetVariant(String path) { |
| final regex = RegExp(r'(?:^|/)(\d+(?:\.\d+)?)x/'); |
| final match = regex.firstMatch(path); |
| if (match != null) { |
| final dprStr = match.group(1)!; |
| final dpr = double.tryParse(dprStr); |
| final matchText = match.group(0)!; |
| String mainKey; |
| if (matchText.startsWith('/')) { |
| mainKey = path.replaceFirst(matchText, '/'); |
| } else { |
| mainKey = path.replaceFirst(matchText, ''); |
| } |
| return (mainKey: mainKey, dpr: dpr); |
| } |
| return (mainKey: path, dpr: null); |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| return .fragment( |
| [ |
| TextButton( |
| label: _running ? 'Preparing bundle...' : 'Share', |
| icon: _running ? null : shareIcon(size: 18.0), |
| isLoading: _running, |
| tooltip: 'Package your app for sharing', |
| disabled: component.disabled || _running, |
| onClick: (component.disabled || _running) |
| ? null |
| : () async { |
| if (!await _runPreflightChecks()) { |
| return; |
| } |
| final commandBus = context.get<AppCommandBus>(); |
| commandBus.dispatch(const TogglePanelCommand(PanelType.editor, visible: true)); |
| commandBus.dispatch(const TogglePanelCommand(PanelType.console, visible: true)); |
| commandBus.dispatch(const ClearDebugConsoleCommand()); |
| await _compileToWasmAction(); |
| }, |
| ), |
| if (_shareUrl != null) |
| ShareDialog( |
| shareUrl: _shareUrl!, |
| onCopy: () { |
| web.window.navigator.clipboard.writeText(_shareUrl!).toDart; |
| commandBus.dispatch(const LogConsoleMessageCommand('[WASM] Share link copied to clipboard.')); |
| }, |
| onClose: () { |
| setState(() { |
| _shareUrl = null; |
| }); |
| }, |
| ), |
| ], |
| ); |
| } |
| } |
| |
| class WebBytesBuilder { |
| final List<int> _bytes = []; |
| |
| void addByte(int byte) { |
| _bytes.add(byte); |
| } |
| |
| void add(List<int> bytes) { |
| _bytes.addAll(bytes); |
| } |
| |
| Uint8List takeBytes() { |
| return Uint8List.fromList(_bytes); |
| } |
| } |
| |
| class SimpleStandardMessageEncoder { |
| final WebBytesBuilder _builder = WebBytesBuilder(); |
| |
| Uint8List get bytes => _builder.takeBytes(); |
| |
| void putUint8(int value) { |
| _builder.addByte(value); |
| } |
| |
| void putUint16(int value) { |
| _builder.addByte(value & 0xff); |
| _builder.addByte((value >> 8) & 0xff); |
| } |
| |
| void putUint32(int value) { |
| _builder.addByte(value & 0xff); |
| _builder.addByte((value >> 8) & 0xff); |
| _builder.addByte((value >> 16) & 0xff); |
| _builder.addByte((value >> 24) & 0xff); |
| } |
| |
| void putUint8List(List<int> list) { |
| _builder.add(list); |
| } |
| |
| void writeSize(int value) { |
| if (value < 254) { |
| putUint8(value); |
| } else if (value <= 0xffff) { |
| putUint8(254); |
| putUint16(value); |
| } else { |
| putUint8(255); |
| putUint32(value); |
| } |
| } |
| |
| void writeValue(Object? value) { |
| if (value == null) { |
| putUint8(0); // _valueNull |
| } else if (value is bool) { |
| putUint8(value ? 1 : 2); // _valueTrue / _valueFalse |
| } else if (value is String) { |
| putUint8(7); // _valueString |
| final utf8Bytes = utf8.encode(value); |
| writeSize(utf8Bytes.length); |
| putUint8List(utf8Bytes); |
| } else if (value is List) { |
| putUint8(12); // _valueList |
| writeSize(value.length); |
| for (final item in value) { |
| writeValue(item); |
| } |
| } else if (value is Map) { |
| putUint8(13); // _valueMap |
| writeSize(value.length); |
| value.forEach((key, val) { |
| writeValue(key); |
| writeValue(val); |
| }); |
| } else { |
| throw ArgumentError('Unsupported type: ${value.runtimeType}'); |
| } |
| } |
| } |