11import 'dart:io' ;
22import 'dart:async' ;
3+ import 'dart:convert' ;
34import 'dart:typed_data' ;
45import 'package:http/http.dart' as http;
56import 'package:path/path.dart' as path;
67import 'package:cbor/cbor.dart' ;
7- import 'package:zstandard/zstandard.dart' ;
88import 'package:thirds/blake3.dart' ;
99import '../models/project.dart' ;
1010import 'task_service.dart' ;
1111
1212class LaunchService {
1313 final TaskService _taskService = TaskService ();
14- static const String _apiPrefix = '/api/cli/ 0' ;
14+ static const String _apiPrefix = '/api/0' ;
1515 final Map <String , bool > _cancellationTokens = {};
1616 final http.Client _httpClient = http.Client ();
1717 static const Duration _httpTimeout = Duration (seconds: 30 );
1818
19+ /// Normalize service domain to full URL with https://
20+ String _normalizeServiceUrl (String service) {
21+ if (service.startsWith ('http://' ) || service.startsWith ('https://' )) {
22+ return service;
23+ }
24+ return 'https://$service ' ;
25+ }
26+
27+ /// Discover the hosting provider's protocol and base URL
28+ /// Returns a record of (protocol, baseUrl)
29+ Future <({String protocol, String baseUrl})> _discoverProtocol (
30+ String serviceDomain,
31+ ) async {
32+ final serviceUrl = _normalizeServiceUrl (serviceDomain);
33+ final wellKnownUrl = '$serviceUrl /.well-known/web-publication-protocol' ;
34+
35+ try {
36+ final response = await _httpClient
37+ .get (Uri .parse (wellKnownUrl))
38+ .timeout (_httpTimeout);
39+
40+ if (response.statusCode != 200 ) {
41+ throw Exception (
42+ 'Failed to discover protocol: HTTP ${response .statusCode }' ,
43+ );
44+ }
45+
46+ // Parse JSON response
47+ final Map <String , dynamic > json;
48+ try {
49+ json = jsonDecode (response.body) as Map <String , dynamic >;
50+ } catch (e) {
51+ throw Exception ('Failed to parse protocol discovery response: $e ' );
52+ }
53+
54+ final protocol = json['protocol' ] as String ? ;
55+ final baseUrl = json['url' ] as String ? ;
56+
57+ if (protocol == null || protocol.isEmpty) {
58+ throw Exception (
59+ 'Protocol field missing or empty in discovery response' ,
60+ );
61+ }
62+
63+ // Validate protocol
64+ if (protocol != 'xmit/0' ) {
65+ throw Exception (
66+ 'Unknown protocol: $protocol . Expected xmit/0' ,
67+ );
68+ }
69+
70+ if (baseUrl == null || baseUrl.isEmpty) {
71+ throw Exception (
72+ 'URL field missing or empty in discovery response' ,
73+ );
74+ }
75+
76+ return (protocol: protocol, baseUrl: baseUrl);
77+ } catch (e) {
78+ throw Exception ('Protocol discovery failed: $e ' );
79+ }
80+ }
81+
1982 /// Cancel a launch by its identifier
2083 void cancelLaunch (String launchId) {
2184 _cancellationTokens[launchId] = true ;
@@ -253,7 +316,39 @@ class LaunchService {
253316 return LaunchResult .failure ('Launch cancelled' );
254317 }
255318
256- // Step 1: Create bundle node structure from project files
319+ // Step 1: Discover protocol and base URL
320+ _currentStep = LaunchStep (
321+ title: 'Discovering protocol' ,
322+ status: LaunchStepStatus .running,
323+ );
324+ onStepUpdate? .call (_currentStep! );
325+ _logToStep ('Discovering protocol from ${target .service }' , onStepUpdate);
326+
327+ String serviceUrl;
328+ try {
329+ final discovery = await _discoverProtocol (target.service);
330+ serviceUrl = discovery.baseUrl;
331+ _logToStep ('✅ Protocol: ${discovery .protocol }' , onStepUpdate);
332+ _logToStep ('✅ Base URL: $serviceUrl ' , onStepUpdate);
333+ } catch (e) {
334+ _currentStep = _currentStep! .copyWith (
335+ status: LaunchStepStatus .failed,
336+ message: e.toString (),
337+ );
338+ onStepUpdate? .call (_currentStep! );
339+ return LaunchResult .failure ('Protocol discovery failed: $e ' );
340+ }
341+
342+ if (_isCancelled (launchId)) {
343+ return LaunchResult .failure ('Launch cancelled' );
344+ }
345+
346+ _currentStep = _currentStep! .copyWith (
347+ status: LaunchStepStatus .completed,
348+ );
349+ onStepUpdate? .call (_currentStep! );
350+
351+ // Step 2: Create bundle node structure from project files
257352 _currentStep = LaunchStep (
258353 title: 'Creating bundle' ,
259354 status: LaunchStepStatus .running,
@@ -274,7 +369,7 @@ class LaunchService {
274369 );
275370 onStepUpdate? .call (_currentStep! );
276371
277- // Step 2 : Suggest bundle to check if already present
372+ // Step 3 : Suggest bundle to check if already present
278373 _currentStep = LaunchStep (
279374 title: 'Suggesting bundle' ,
280375 status: LaunchStepStatus .running,
@@ -285,8 +380,7 @@ class LaunchService {
285380 return LaunchResult .failure ('Launch cancelled' );
286381 }
287382
288- final suggestUrl = '${target .service }$_apiPrefix /suggest' ;
289- _logToStep ('Connecting to ${target .service }' , onStepUpdate);
383+ final suggestUrl = '$serviceUrl $_apiPrefix /suggest' ;
290384
291385 var suggestResponse = await _suggestBundle (
292386 launchId,
@@ -320,7 +414,7 @@ class LaunchService {
320414
321415 teamId = await _handleTeamSelection (
322416 launchId,
323- target.service ,
417+ serviceUrl ,
324418 authKey,
325419 onStepUpdate,
326420 onTeamSelectionRequired,
@@ -420,7 +514,7 @@ class LaunchService {
420514 message: '${suggestResponse .missing .length } parts' ,
421515 );
422516 onStepUpdate? .call (_currentStep! );
423- final missingUrl = '${ target . service } $_apiPrefix /missing' ;
517+ final missingUrl = '$serviceUrl $_apiPrefix /missing' ;
424518 await _uploadMissingParts (
425519 launchId,
426520 missingUrl,
@@ -444,7 +538,7 @@ class LaunchService {
444538 status: LaunchStepStatus .running,
445539 );
446540 onStepUpdate? .call (_currentStep! );
447- final finalizeUrl = '${ target . service } $_apiPrefix /finalize' ;
541+ final finalizeUrl = '$serviceUrl $_apiPrefix /finalize' ;
448542 final finalizeResponse = await _finalizeLaunch (
449543 launchId,
450544 finalizeUrl,
@@ -496,7 +590,7 @@ class LaunchService {
496590 );
497591 }
498592
499- // Step 3 : Upload bundle
593+ // Step 4 : Upload bundle
500594 _currentStep = LaunchStep (
501595 title: 'Uploading bundle' ,
502596 status: LaunchStepStatus .running,
@@ -512,7 +606,7 @@ class LaunchService {
512606 return LaunchResult .failure ('Launch cancelled' );
513607 }
514608
515- final uploadUrl = '${ target . service } $_apiPrefix /bundle' ;
609+ final uploadUrl = '$serviceUrl $_apiPrefix /bundle' ;
516610 final bundleSizeKb = (bundleBytes.length / 1024 ).toStringAsFixed (2 );
517611 _logToStep ('Uploading ${bundleSizeKb }KB bundle' , onStepUpdate);
518612
@@ -572,15 +666,15 @@ class LaunchService {
572666 ),
573667 );
574668
575- // Step 4 : Upload missing parts if any
669+ // Step 5 : Upload missing parts if any
576670 if (uploadResponse.missing.isNotEmpty) {
577671 _currentStep = LaunchStep (
578672 title: 'Uploading missing parts' ,
579673 status: LaunchStepStatus .running,
580674 message: '${uploadResponse .missing .length } parts' ,
581675 );
582676 onStepUpdate? .call (_currentStep! );
583- final missingUrl = '${ target . service } $_apiPrefix /missing' ;
677+ final missingUrl = '$serviceUrl $_apiPrefix /missing' ;
584678 await _uploadMissingParts (
585679 launchId,
586680 missingUrl,
@@ -597,15 +691,15 @@ class LaunchService {
597691 onStepUpdate? .call (_currentStep! );
598692 }
599693
600- // Step 5 : Finalize launch
694+ // Step 6 : Finalize launch
601695 _currentStep = LaunchStep (
602696 title: 'Finalizing launch' ,
603697 status: LaunchStepStatus .running,
604698 );
605699 onStepUpdate? .call (_currentStep! );
606700 _logToStep ('Requesting finalization' , onStepUpdate);
607701
608- final finalizeUrl = '${ target . service } $_apiPrefix /finalize' ;
702+ final finalizeUrl = '$serviceUrl $_apiPrefix /finalize' ;
609703 final finalizeResponse = await _finalizeLaunch (
610704 launchId,
611705 finalizeUrl,
@@ -866,8 +960,8 @@ class LaunchService {
866960 fields.map ((k, v) => MapEntry (CborSmallInt (k), v)),
867961 );
868962 final encoded = cbor.encode (requestCbor);
869- final compressed = await Uint8List . fromList (encoded). compress ( );
870- return compressed ?? Uint8List ( 0 );
963+ final compressed = gzip. encode (encoded);
964+ return Uint8List . fromList (compressed );
871965 }
872966
873967 /// Make a CBOR POST request and decode the response
@@ -881,7 +975,10 @@ class LaunchService {
881975 launchId,
882976 () => _httpClient.post (
883977 Uri .parse (url),
884- headers: {'Content-Type' : 'application/cbor+zstd' },
978+ headers: {
979+ 'Content-Type' : 'application/cbor+gzip' ,
980+ 'Accept' : 'application/cbor+gzip' ,
981+ },
885982 body: compressedBody,
886983 ),
887984 );
@@ -891,10 +988,7 @@ class LaunchService {
891988 }
892989
893990 // Decompress and decode response
894- final decompressed = await response.bodyBytes.decompress ();
895- if (decompressed == null ) {
896- throw Exception ('Failed to decompress response' );
897- }
991+ final decompressed = gzip.decode (response.bodyBytes);
898992 final decoded = cbor.decode (decompressed) as CborMap ;
899993 return (decoded, response.statusCode);
900994 }
@@ -1083,7 +1177,10 @@ class LaunchService {
10831177 final response = await _httpClient
10841178 .post (
10851179 Uri .parse (url),
1086- headers: {'Content-Type' : 'application/cbor+zstd' },
1180+ headers: {
1181+ 'Content-Type' : 'application/cbor+gzip' ,
1182+ 'Accept' : 'application/cbor+gzip' ,
1183+ },
10871184 body: compressed,
10881185 )
10891186 .timeout (
@@ -1099,10 +1196,7 @@ class LaunchService {
10991196 throw Exception ('List teams failed: ${response .statusCode }' );
11001197 }
11011198
1102- final decompressed = await response.bodyBytes.decompress ();
1103- if (decompressed == null ) {
1104- throw Exception ('Failed to decompress response' );
1105- }
1199+ final decompressed = gzip.decode (response.bodyBytes);
11061200 final decoded = cbor.decode (decompressed) as CborMap ;
11071201
11081202 // Extract management URL (key 6)
0 commit comments