import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; /// Base del feed de actualizaciones, servido como archivos estáticos desde /// GitHub Pages del repo público `Peblo423/soma-pro-releases`. /// Override con `--dart-define=FEED_BASE_URL=...` para pruebas locales. const _feedBaseUrl = String.fromEnvironment( 'FEED_BASE_URL', defaultValue: 'https://peblo423.github.io/soma-pro-releases', ); typedef StatusCallback = void Function(String status); class UpdateManifest { final String version; final String url; final String sha256; UpdateManifest({required this.version, required this.url, required this.sha256}); factory UpdateManifest.fromJson(Map json) => UpdateManifest( version: json['version'] as String, url: json['url'] as String, sha256: json['sha256'] as String, ); } class UpdaterService { UpdaterService._(); static String get _platformFolder { if (Platform.isWindows) return 'windows'; if (Platform.isMacOS) return 'macos'; if (Platform.isLinux) return 'linux'; throw UnsupportedError('Plataforma no soportada: ${Platform.operatingSystem}'); } /// Nombre del ejecutable/bundle de la app real. Debe coincidir con /// BINARY_NAME en windows/linux CMakeLists.txt y PRODUCT_NAME en /// macos/Runner/Configs/AppInfo.xcconfig del proyecto principal. static String get _appExeName { if (Platform.isWindows) return 'SOMA_PRO.exe'; if (Platform.isMacOS) return 'SOMA PRO.app'; if (Platform.isLinux) return 'SOMA_PRO'; throw UnsupportedError('Plataforma no soportada: ${Platform.operatingSystem}'); } static Directory get _installDir { if (Platform.isMacOS) { // En macOS el exe vive dentro de .app/Contents/MacOS/exe — subimos 4 niveles // para llegar a la carpeta que contiene el bundle .app. return File(Platform.resolvedExecutable).parent.parent.parent.parent; } return File(Platform.resolvedExecutable).parent; } /// Carpeta donde vive la app real. En Windows/Linux el build de Flutter /// genera una carpeta plana (`data/`, `flutter_windows.dll`, etc.) que /// chocaría con la del updater si estuvieran al mismo nivel, así que la /// app vive en una subcarpeta `app/`. En macOS no hace falta: cada `.app` /// ya es una carpeta autocontenida, pueden ser hermanos. static Directory get _appDir => Platform.isMacOS ? _installDir : Directory(p.join(_installDir.path, 'app')); static String get _appTarget => p.join(_appDir.path, _appExeName); static File get _versionFile => File(p.join(_installDir.path, 'version.json')); static String _readLocalVersion() { if (!_versionFile.existsSync()) return '0.0.0'; try { final json = jsonDecode(_versionFile.readAsStringSync()) as Map; return json['version'] as String? ?? '0.0.0'; } catch (_) { return '0.0.0'; } } static void _writeLocalVersion(String version) { _versionFile.writeAsStringSync(jsonEncode({'version': version})); } /// Compara versiones semánticas simples (`1.2.3`). Devuelve > 0 si [a] es /// más nueva que [b]. static int _compareVersions(String a, String b) { final partsA = a.split('.').map(int.parse).toList(); final partsB = b.split('.').map(int.parse).toList(); for (var i = 0; i < 3; i++) { final diff = (partsA.length > i ? partsA[i] : 0) - (partsB.length > i ? partsB[i] : 0); if (diff != 0) return diff; } return 0; } /// Chequea el feed, descarga y aplica una actualización si corresponde. /// No lanza si falla el chequeo (sin internet, feed caído, etc.) — la app /// instalada actualmente sigue siendo válida para abrir. static Future checkAndUpdate({required StatusCallback onStatus}) async { onStatus('Chequeando actualizaciones...'); final manifestUrl = '$_feedBaseUrl/$_platformFolder/manifest.json'; final response = await http.get(Uri.parse(manifestUrl)).timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { throw HttpException('Feed respondió ${response.statusCode}'); } final manifest = UpdateManifest.fromJson(jsonDecode(response.body) as Map); final localVersion = _readLocalVersion(); if (_compareVersions(manifest.version, localVersion) <= 0) { onStatus('SOMA PRO está actualizado'); return; } onStatus('Descargando actualización ${manifest.version}...'); final zipBytes = (await http.get(Uri.parse(manifest.url))).bodyBytes; final actualHash = sha256.convert(zipBytes).toString(); if (actualHash.toLowerCase() != manifest.sha256.toLowerCase()) { throw const FormatException('El paquete descargado no coincide con el hash esperado'); } onStatus('Instalando actualización...'); if (Platform.isMacOS) { await _installMacUpdate(zipBytes); } else { _appDir.createSync(recursive: true); _extractZip(zipBytes, _appDir); if (Platform.isLinux) { // En Linux `_appTarget` es el binario real; en Windows no hace falta. await _markExecutable(_appTarget); } } _writeLocalVersion(manifest.version); } static void _extractZip(List zipBytes, Directory targetDir) { final archive = ZipDecoder().decodeBytes(zipBytes); for (final file in archive) { final outPath = p.join(targetDir.path, file.name); if (file.isFile) { File(outPath) ..createSync(recursive: true) ..writeAsBytesSync(file.content as List); } else { Directory(outPath).createSync(recursive: true); } } } static Future _markExecutable(String path) async { if (!File(path).existsSync()) return; await Process.run('chmod', ['+x', path]); } /// Instala el update en macOS. Un `.app` es un bundle con un binario que /// necesita el bit de ejecución y con symlinks dentro de sus frameworks; /// descomprimirlo con el paquete `archive` pierde ambos y la app deja de /// abrir. Por eso en Mac usamos `ditto`, la herramienta de Apple para /// empaquetar bundles, que preserva permisos y symlinks — y el Action arma /// el zip también con `ditto`, así el viaje es simétrico. /// /// Extraemos a un staging dentro de la misma carpeta de instalación (mismo /// volumen) y recién ahí reemplazamos el bundle viejo con un rename atómico, /// para no dejar la app a medio pisar si algo falla en el medio. static Future _installMacUpdate(List zipBytes) async { _appDir.createSync(recursive: true); final stageDir = Directory(p.join(_appDir.path, '.soma_update_stage')); if (stageDir.existsSync()) stageDir.deleteSync(recursive: true); stageDir.createSync(recursive: true); final tmpZip = File(p.join(stageDir.path, 'update.zip')); try { tmpZip.writeAsBytesSync(zipBytes); final result = await Process.run('ditto', ['-x', '-k', tmpZip.path, stageDir.path]); if (result.exitCode != 0) { throw ProcessException('ditto', const ['-x', '-k'], result.stderr.toString(), result.exitCode); } final staged = Directory(p.join(stageDir.path, _appExeName)); if (!staged.existsSync()) { throw FileSystemException( 'El paquete de actualización no contiene $_appExeName', stageDir.path); } final target = Directory(_appTarget); if (target.existsSync()) target.deleteSync(recursive: true); staged.renameSync(target.path); // Red de seguridad: aunque `ditto` ya preserva permisos, aseguramos el // binario interno ejecutable y sacamos la cuarentena por las dudas (los // updates se bajan por HTTP y no deberían traerla, pero cuesta nada). final innerBinary = p.join( target.path, 'Contents', 'MacOS', p.basenameWithoutExtension(_appExeName)); await _markExecutable(innerBinary); await Process.run('xattr', ['-dr', 'com.apple.quarantine', target.path]); } finally { if (stageDir.existsSync()) stageDir.deleteSync(recursive: true); } } /// Lanza la app real y devuelve sin esperar a que termine. static Future launchApp() async { if (Platform.isMacOS) { // `-n` fuerza una instancia nueva en vez de reactivar una ya existente: // sin esto, si macOS considera que "SOMA PRO.app" ya está corriendo // (por ejemplo por la demora entre que el proceso anterior llama // exit(0) y que Launch Services lo da de baja), `open` solo la trae al // frente y descarta `--args`, así que la app nunca ve `--from-updater` // y vuelve a redirigirse acá — un ping-pong infinito entre updater y app. await Process.start('open', ['-n', _appTarget, '--args', '--from-updater'], mode: ProcessStartMode.detached); return; } await Process.start(_appTarget, const ['--from-updater'], mode: ProcessStartMode.detached); } }