Agrego updater app

This commit is contained in:
Pablo
2026-08-22 19:09:38 -03:00
parent 2d0797e627
commit 11e36bd6c2
60 changed files with 3942 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
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<String, dynamic> 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<String, dynamic>;
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<void> 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<String, dynamic>);
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<int> 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<int>);
} else {
Directory(outPath).createSync(recursive: true);
}
}
}
static Future<void> _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<void> _installMacUpdate(List<int> 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<void> 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);
}
}