| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:js_interop'; |
| import 'dart:typed_data'; |
| |
| import 'package:archive/archive.dart'; |
| import 'package:crypto/crypto.dart'; |
| import 'package:handshake_version/handshake_version.dart'; |
| import 'package:jaspr/jaspr.dart'; |
| import 'package:logging/logging.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:qr/qr.dart'; |
| import 'package:web/web.dart' as web; |
| |
| import '../../../utils/provider.dart'; |
| import '../../../utils/web_webrtc_manager.dart'; |
| import '../../bottompanel/view_models/diagnostics_view_model.dart'; |
| import '../../shared/components/material_icon.dart'; |
| 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.onWeb('package:firebase_storage/firebase_storage.dart', show: [#FirebaseStorage]) |
| import 'device_preview_button.imports.dart'; |
| import 'qr_dialog.dart'; |
| |
| class DevicePreviewButton extends StatefulComponent { |
| const DevicePreviewButton({this.disabled = false, super.key}); |
| |
| final bool disabled; |
| |
| @override |
| State<DevicePreviewButton> createState() => _DevicePreviewButtonState(); |
| } |
| |
| class _DevicePreviewButtonState extends State<DevicePreviewButton> { |
| bool _running = false; |
| QrImage? _qrImage; |
| String? _downloadUrl; |
| |
| late WebWebRTCManager webRTCManager; |
| late AppCommandBus commandBus; |
| |
| @override |
| void initState() { |
| super.initState(); |
| webRTCManager = context.get<WebWebRTCManager>(); |
| webRTCManager.addListener(_onP2PChanged); |
| commandBus = context.get<AppCommandBus>(); |
| } |
| |
| @override |
| void dispose() { |
| webRTCManager.removeListener(_onP2PChanged); |
| super.dispose(); |
| } |
| |
| void _onP2PChanged() { |
| final scanned = webRTCManager.state == P2pState.connecting || webRTCManager.state == P2pState.connected; |
| if (scanned && _qrImage != null) { |
| setState(() { |
| _qrImage = null; |
| _downloadUrl = null; |
| }); |
| } else { |
| setState(() {}); |
| } |
| } |
| |
| /// Runs pre-flight checks before compiling bytecode for device preview. |
| /// Returns `true` if compilation should proceed. |
| 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> _compileToBytecodeAction() async { |
| final workspaceRepository = context.get<WorkspaceRepository>(); |
| |
| setState(() { |
| _running = true; |
| _qrImage = null; |
| _downloadUrl = null; |
| }); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Preparing device preview...')); |
| |
| var startedHandshake = false; |
| try { |
| // Run bytecode compilation/upload and WebRTC handshake preparation in parallel |
| final compileAndUploadFuture = Future(() async { |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Running bytecode compilation...')); |
| |
| final result = await workspaceRepository.compileToBytecode('lib/main.dart'); |
| commandBus.dispatch(LogConsoleMessageCommand(result.log)); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Bundling assets and bytecode...')); |
| final workspace = workspaceRepository.workspace; |
| final assetEntries = <String>[]; |
| if (await workspace.fileExist('pubspec.yaml')) { |
| final pubspecContent = await workspace.readFileAsText('pubspec.yaml'); |
| assetEntries.addAll(_parsePubspecAssets(pubspecContent)); |
| } |
| |
| final allFiles = await workspaceRepository.listVisibleWorkspaceFiles(); |
| final assetFiles = <String>[]; |
| for (final filePath in allFiles) { |
| if (_isFileAsset(filePath, assetEntries)) { |
| assetFiles.add(filePath); |
| } |
| } |
| |
| final archive = Archive(); |
| archive.addFile(ArchiveFile.bytes('bytecode.bytecode', result.bytes)); |
| for (final assetPath in assetFiles) { |
| final assetBytes = await workspace.readFileAsBytes(assetPath); |
| archive.addFile(ArchiveFile.bytes(assetPath, assetBytes)); |
| } |
| |
| final zipEncoder = ZipEncoder(); |
| final zipBytes = Uint8List.fromList(zipEncoder.encode(archive)); |
| |
| if (webRTCManager.connected) { |
| final sent = webRTCManager.sendBytecode(zipBytes); |
| if (sent) { |
| commandBus.dispatch( |
| const LogConsoleMessageCommand('[P2P] Bytecode and assets archive sent directly over P2P WebRTC.'), |
| ); |
| return (downloadUrl: '', sentDirectly: true); |
| } |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Uploading archive to Firebase...')); |
| |
| final storage = FirebaseStorage.instance; |
| final hash = md5.convert(zipBytes).toString(); |
| |
| final ref = storage.ref('bytecode/$hash.zip'); |
| await ref.putData(zipBytes); |
| final downloadUrl = await ref.getDownloadURL(); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Archive uploaded to Firebase.')); |
| return (downloadUrl: downloadUrl, sentDirectly: false); |
| }); |
| |
| // Prepare WebRTC in parallel with compile/upload. If it fails, the QR |
| // still works through the Firebase download URL fallback. |
| final handshakeFuture = webRTCManager.connected |
| ? Future<P2pHandshakeSession?>.value() |
| : webRTCManager.prepareHandshake().catchError((Object e, StackTrace st) { |
| startedHandshake = true; |
| commandBus.dispatch( |
| LogConsoleMessageCommand('[P2P] Handshake failed (non-critical): $e', level: Level.WARNING), |
| ); |
| web.console.error(e.toJSBox); |
| web.console.error(st.toString().toJS); |
| return null; |
| }); |
| startedHandshake = !webRTCManager.connected; |
| |
| // Wait for compile+upload first; direct P2P sends do not need a QR code. |
| final compileResult = await compileAndUploadFuture; |
| |
| if (compileResult.sentDirectly || compileResult.downloadUrl.isEmpty) { |
| return; |
| } |
| |
| final downloadUrl = compileResult.downloadUrl; |
| final handshake = await handshakeFuture; |
| |
| final payloadJson = jsonEncode({ |
| 'bytecode_url': downloadUrl, |
| 'handshake_version': handshakeVersion, |
| 'p2p_session_id': ?handshake?.sessionId, |
| }); |
| |
| final qrCode = QrCode( |
| payload: QrPayload.fromString(payloadJson), |
| errorCorrectLevel: QrErrorCorrectLevel.medium, |
| ); |
| final qrImage = QrImage(qrCode); |
| |
| setState(() { |
| _qrImage = qrImage; |
| _downloadUrl = downloadUrl; |
| }); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('[Preview] Device preview ready.')); |
| commandBus.dispatch(LogConsoleMessageCommand('[Preview] Download URL: $downloadUrl')); |
| } catch (e, st) { |
| if (startedHandshake && !webRTCManager.connected) { |
| webRTCManager.cancelHandshake(); |
| } |
| web.console.error('Error while preparing device preview:'.toJS); |
| web.console.error(e.toJSBox); |
| web.console.error(st.toString().toJS); |
| commandBus.dispatch( |
| LogConsoleMessageCommand( |
| '[Preview] Error while preparing device preview', |
| level: Level.SEVERE, |
| error: e, |
| stackTrace: st, |
| ), |
| ); |
| } finally { |
| setState(() => _running = false); |
| } |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| return .fragment( |
| [ |
| TextButton( |
| label: _running ? 'Compiling...' : 'Preview on Device', |
| icon: _running |
| ? null |
| : materialIcon( |
| MyMaterialIcons.mobileCast, |
| size: 18.0, |
| viewBox: '0 -960 960 960', |
| color: 'currentColor', |
| ), |
| isLoading: _running, |
| tooltip: 'Preview on your device', |
| 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 _compileToBytecodeAction(); |
| }, |
| ), |
| if (_qrImage != null) |
| QrDialog( |
| qrImage: _qrImage!, |
| title: 'Preview on Device', |
| subtitle: 'Scan this QR code with the VibePad companion app to preview on a real device.', |
| onCopy: () { |
| web.window.navigator.clipboard.writeText(_downloadUrl!).toDart; |
| commandBus.dispatch(const LogConsoleMessageCommand('Download link copied to clipboard.')); |
| }, |
| onClose: () { |
| setState(() { |
| _qrImage = null; |
| _downloadUrl = null; |
| }); |
| webRTCManager.cancelHandshake(); |
| }, |
| ), |
| ], |
| ); |
| } |
| } |
| |
| List<String> _parsePubspecAssets(String pubspecContent) { |
| final assets = <String>[]; |
| final lines = pubspecContent.split('\n'); |
| bool inFlutter = false; |
| bool inAssets = false; |
| int flutterIndent = -1; |
| int assetsIndent = -1; |
| |
| for (final line in lines) { |
| final cleanLine = line.split('#').first; |
| if (cleanLine.trim().isEmpty) { |
| continue; |
| } |
| |
| final trimmed = cleanLine.trimLeft(); |
| final indent = line.length - trimmed.length; |
| |
| if (inFlutter) { |
| if (indent <= flutterIndent) { |
| inFlutter = false; |
| inAssets = false; |
| } |
| } |
| if (inAssets) { |
| if (indent <= assetsIndent) { |
| inAssets = false; |
| } |
| } |
| |
| if (!inFlutter && trimmed.startsWith('flutter:')) { |
| inFlutter = true; |
| flutterIndent = indent; |
| continue; |
| } |
| |
| if (inFlutter && !inAssets && trimmed.startsWith('assets:')) { |
| inAssets = true; |
| assetsIndent = indent; |
| continue; |
| } |
| |
| if (inAssets && trimmed.startsWith('-')) { |
| var cleanPath = trimmed.substring(1).trim(); |
| if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) || |
| (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) { |
| cleanPath = cleanPath.substring(1, cleanPath.length - 1); |
| } |
| if (cleanPath.isNotEmpty) { |
| assets.add(cleanPath); |
| } |
| } |
| } |
| return assets; |
| } |
| |
| bool _isFileAsset(String filePath, List<String> assetEntries) { |
| final fileDir = p.posix.dirname(filePath); |
| for (final entry in assetEntries) { |
| final normalizedEntry = entry.endsWith('/') ? entry.substring(0, entry.length - 1) : entry; |
| |
| if (filePath == normalizedEntry) { |
| return true; |
| } |
| |
| if (fileDir == normalizedEntry) { |
| return true; |
| } |
| } |
| return false; |
| } |