blob: 2c3a8e502fc1b1e770332c4b768c0105ce1805ce [file]
// 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}');
}
}
}