blob: 2cac3b9aa674be2aa49d7ada21f28ec39c047dbb [file]
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;
}