import 'dart:io'; import 'dart:typed_data'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; import 'package:uuid/uuid.dart'; import '../../../about/internal/application/DeploymentRecord.dart'; import '../../../about/internal/application/ServerProfile.dart'; import '../../../about/internal/application/ServiceConfig.dart'; import '../../../about/internal/application/StepTracker.dart'; import '../../../comms/Comms.dart' show DeployResult, LogLine, TransferProgress; import '../../../utils/CommonUtils.dart'; import '../../parent/ParentViewModel.dart'; import 'ConnectDeploy.dart'; class ViewDeploy extends ParentViewModel { ConnectDeploy connection; ViewDeploy(super.context, this.connection); /// Both the SSH session and the local build write to the same log stream on /// screen, so the console reads as one continuous story. Stream get logs => getDataManager().logs; Stream get buildLogs => getDataManager().buildLogs; Stream> get steps => getDataManager().stepStream; Stream> get buildSteps => getDataManager().buildStepStream; Stream get transferProgress => getDataManager().transferProgress; bool get isConnected => getDataManager().isConnected; bool get canRevert => getDataManager().canRevert; bool get buildSupported => getDataManager().buildSupported; // ── staging ──────────────────────────────────────────────────────────────── /// Reads where nginx actually serves from, so a mismatched web root can be /// flagged before the user waits through a deploy that won't show up. void loadNginxRoots(ServerProfile server) async { try { final roots = await getDataManager().nginxRoots(server); connection.onNginxRootsLoaded(roots); } catch (_) { connection.onNginxRootsLoaded(const []); } } /// Picks a jar (or any file, for a generic service). void pickArtifact(bool anyFile) async { try { final picked = anyFile ? await FilePicker.pickFiles(withData: true) : await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['jar'], withData: true, ); if (picked == null || picked.files.isEmpty) return; final file = picked.files.first; final bytes = file.bytes ?? await File(file.path!).readAsBytes(); connection.onJarStaged(bytes, file.name, 'Dropped file'); } catch (error) { handleError(error, () => pickArtifact(anyFile), () {}, "Retry"); } } /// Picks the folder holding a built site. void pickWebFolder() async { try { final directory = await FilePicker.getDirectoryPath( dialogTitle: 'Select the built site folder (contains index.html)', ); if (directory == null) return; connection.onWebStaged(directory, 'Selected folder'); } catch (error) { handleError(error, pickWebFolder, () {}, "Retry"); } } /// Accepts a drag-and-drop payload, rejecting anything the service can't use. void stageDropped(DropDoneDetails details, bool isWeb, bool isGeneric) async { if (details.files.isEmpty) return; try { if (isWeb) { final entity = details.files.first; if (FileSystemEntity.isDirectorySync(entity.path)) { connection.onWebStaged(entity.path, 'Dropped folder'); } else { connection.onBuildFailed('Drop the built site folder here.'); } return; } if (isGeneric) { final file = details.files.first; if (FileSystemEntity.isDirectorySync(file.path)) { connection.onBuildFailed('Drop a single file, not a folder.'); return; } connection.onJarStaged( await file.readAsBytes(), file.name, 'Dropped file', ); return; } final jar = details.files.firstWhere( (f) => f.name.toLowerCase().endsWith('.jar'), orElse: () => details.files.first, ); if (!jar.name.toLowerCase().endsWith('.jar')) { connection.onBuildFailed('That is not a .jar file.'); return; } connection.onJarStaged(await jar.readAsBytes(), jar.name, 'Dropped file'); } catch (error) { handleError(error, () {}, () {}, "Okay"); } } /// Picks the source project folder and remembers it on the service. void pickProjectDirectory(ServerProfile server, ServiceConfig service) async { try { final directory = await FilePicker.getDirectoryPath( dialogTitle: 'Select the service project folder', ); if (directory == null) return; await saveProjectPath(server, service, directory); } catch (error) { handleError( error, () => pickProjectDirectory(server, service), () {}, "Retry", ); } } /// Persists the project path so the next deploy doesn't ask again. Future saveProjectPath( ServerProfile server, ServiceConfig service, String path, ) async { if (service.localProjectPath == path) return; service.localProjectPath = path; final servers = await getDataManager().getServers(); final serverIndex = servers.indexWhere((s) => s.id == server.id); if (serverIndex < 0) return; final services = servers[serverIndex].services; final serviceIndex = services.indexWhere((s) => s.id == service.id); if (serviceIndex < 0) return; services[serviceIndex] = service; await getDataManager().setServers(servers); } // ── build ────────────────────────────────────────────────────────────────── /// Compiles the project and stages whatever it produces. void buildFromSource(ServiceConfig service, bool isWeb) async { try { final result = isWeb ? await getDataManager().buildWeb(service) : await getDataManager().build(service); if (!result.ok) { connection.onBuildFailed(result.message); return; } if (isWeb) { connection.onWebStaged(result.dir!.path, 'Built from source'); } else { final jar = result.jar!; connection.onJarStaged( await jar.readAsBytes(), _basename(jar.path), 'Built from source', ); } } catch (error) { connection.onBuildFailed(error.toString()); } } // ── deploy ───────────────────────────────────────────────────────────────── /// Ships a staged jar/binary, then records the outcome in the history. void deployJar( ServerProfile server, ServiceConfig service, Uint8List bytes, String label, ) async { if (!await canReach( server, () => deployJar(server, service, bytes, label), )) { return; } DeployResult result; try { result = await getDataManager().deploy( server: server, service: service, jarBytes: bytes, jarLabel: label, ); } catch (error) { result = DeployResult(false, canRevert, 'Error: $error'); } await _record( server, service, result, label, service.remotePath(label.isEmpty ? '' : label), ); connection.onDeployFinished(result); } /// Packages the built site, then publishes it to nginx. void deployWeb( ServerProfile server, ServiceConfig service, String directory, ) async { if (!await canReach(server, () => deployWeb(server, service, directory))) { return; } // The archive is extracted at the web root, so index.html has to be at the // folder's top level — for Angular that's dist//browser, which is // an easy directory to miss. if (!File('$directory/index.html').existsSync()) { connection.onDeployFinished( DeployResult( false, false, 'No index.html in "$directory". Point at the folder that CONTAINS ' 'index.html (for Angular that\'s dist//browser).', ), ); return; } DeployResult result; try { connection.onPackagingStarted(); final (bytes, count) = await getDataManager().packageWeb(directory); if (count == 0) { connection.onDeployFinished( DeployResult( false, false, 'That folder is empty — nothing to deploy.', ), ); return; } result = await getDataManager().deployWeb( server: server, service: service, archiveBytes: bytes, fileCount: count, ); } catch (error) { result = DeployResult(false, canRevert, 'Error: $error'); } await _record( server, service, result, _basename(directory), service.remoteRoot, ); connection.onDeployFinished(result); } /// Restores the backup the last deploy left behind. void revert(ServerProfile server, ServiceConfig service) async { try { final result = await getDataManager().revert(); if (result.ok) { await _writeRecord( server, service, DeployStatus.reverted, '(previous)', result.message, service.remoteRoot, ); } connection.onRevertFinished(result); } catch (error) { connection.onRevertFinished(DeployResult(false, false, '$error')); } } /// Drops the backup, committing to the version just deployed. void discardBackup() async { try { await getDataManager().discardBackup(); connection.onBackupDiscarded(); } catch (error) { handleError(error, discardBackup, () {}, "Retry"); } } /// Aborts whichever half of the pipeline is running. void cancel(bool building) { if (building) { getDataManager().cancelBuild(); } else { getDataManager().requestCancel(); } } void disconnect() { getDataManager().disconnect(); connection.onFollowChanged(false); } void toggleFollow( ServerProfile server, ServiceConfig service, bool currentlyFollowing, ) async { if (currentlyFollowing) { getDataManager().stopLogs(); connection.onFollowChanged(false); return; } connection.onFollowChanged(true); try { await getDataManager().followLogs(server, service); } catch (error) { connection.onFollowChanged(false); handleError( error, () => toggleFollow(server, service, false), () {}, "Retry", ); } } // ── history ──────────────────────────────────────────────────────────────── Future _record( ServerProfile server, ServiceConfig service, DeployResult result, String artifact, String target, ) { return _writeRecord( server, service, result.ok ? DeployStatus.success : DeployStatus.failure, artifact, result.message, target, ); } Future _writeRecord( ServerProfile server, ServiceConfig service, DeployStatus status, String artifact, String message, String target, ) async { try { await getDataManager().addDeployment( DeploymentRecord( id: const Uuid().v4(), serverId: server.id, serverName: server.name, serviceId: service.id, serviceLabel: service.name, jarName: artifact, remotePath: target, time: DateTime.now(), status: status, message: message, ), ); } catch (_) { // Losing a history row must never mask the deploy's own outcome, which // the caller is about to report. } } String _basename(String path) => baseName(path); }