225 lines
8.9 KiB
Dart
225 lines
8.9 KiB
Dart
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);
|
|
}
|
|
}
|