Agrego updater app
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'updater_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(420, 320),
|
||||
center: true,
|
||||
backgroundColor: Color(0xFF121212),
|
||||
skipTaskbar: false,
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: 'SOMA PRO',
|
||||
);
|
||||
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
|
||||
runApp(const UpdaterApp());
|
||||
}
|
||||
|
||||
class UpdaterApp extends StatelessWidget {
|
||||
const UpdaterApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
),
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
String _status = 'Chequeando actualizaciones...';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_run();
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
try {
|
||||
await UpdaterService.checkAndUpdate(
|
||||
onStatus: (status) {
|
||||
if (mounted) setState(() => _status = status);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _status = 'Sin conexión, abriendo SOMA PRO...');
|
||||
}
|
||||
}
|
||||
await UpdaterService.launchApp();
|
||||
// Si este proceso muere apenas después de lanzar la app, macOS a veces no
|
||||
// llega a terminar de transferirle el foco: la ventana queda "ocluida"
|
||||
// para el engine de Flutter y nunca dispara el primer frame (pantalla
|
||||
// negra permanente, sin ningún error). Dar un respiro antes de salir le
|
||||
// da tiempo al sistema a completar la activación.
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/logo.png', width: 220),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_status, style: const TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user