Updater: real-layout apply with rollback, in-app update + restart

- Strip the tarball's top-level folder; stage inside the install root
  (same filesystem) and swap with full rollback; keep file modes.
- Semver prerelease ordering (1.2.3-rc1 < 1.2.3).
- UpdateController: background check, download with progress, apply,
  restart; required-update wording for newer protocols; Check now.
- update.sh/update.bat in the bundle; Windows apply path (untested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:41:02 +03:00
1 parent e2301aa4ec
commit beae87b0cc
8 files changed
+1126 -369

No files matched your search

@@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'updater.dart';
// In-app update flow behind the Home update indicator. Same code path as
// `--upgrade` (downloadAndApply); this adds state for the UI and the
// restart. Check failures are logged only — the banner shows up just when
// there is something to install.
final _log = createLogger('update');
enum UpdatePhase { idle, available, downloading, applying, restarting, failed }
class UpdateStatus {
final UpdatePhase phase;
final UpdateManifest? manifest;
final double? progress;
final String? error;
// False for dev runs / unpacked builds: banner tells the user to update
// by hand instead of offering the button.
final bool canInstall;
const UpdateStatus(this.phase,
{this.manifest, this.progress, this.error, this.canInstall = false});
}
typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes.
Future<void> _closeAndRestart(String executable, List<String> args) async {
try {
await Hive.close();
} catch (e) {
_log.warning('closing hive before restart: $e');
}
await restartApp(executable, args);
}
class UpdateController {
static final instance = UpdateController();
final String currentVersion;
final String manifestUrl;
final String? executablePath;
final Directory? installRoot;
final RestartFn restart;
final bool skipDevBuilds;
UpdateController(
{this.currentVersion = appVersion,
this.manifestUrl = updateManifestUrl,
this.executablePath,
this.installRoot,
this.restart = _closeAndRestart,
this.skipDevBuilds = true});
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
/// Outcome of the last finished check, for Settings/About wording
/// ("Up to date", "Check failed: ..."); null until one finishes.
final lastCheck = ValueNotifier<String?>(null);
Future<void>? _check;
late final String _exe = executablePath ?? currentExecutablePath();
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
String get _exeName => _exe.replaceAll('\\', '/').split('/').last;
// Once per process; later calls return the same future.
Future<void> checkOnce() => _check ??= _checkNow();
// User-triggered re-check ("Check now"); joins one already in flight.
Future<void> checkNow() {
final inFlight = _check;
if (inFlight != null && _checking) return inFlight;
return _check = _checkNow();
}
bool _checking = false;
Future<void> _checkNow() async {
_checking = true;
try {
await _runCheck();
} finally {
_checking = false;
}
}
Future<void> _runCheck() async {
if (skipDevBuilds && currentVersion.endsWith('-dev')) {
_log.info('dev build ($currentVersion), skipping update check');
lastCheck.value = 'Dev build — update checks are off';
return;
}
final root = _root;
if (root != null) await cleanupUpdateLeftovers(root);
try {
final m = await fetchManifest(url: manifestUrl);
if (!isNewerVersion(currentVersion, m.version)) {
_log.info('up to date ($currentVersion, latest ${m.version})');
lastCheck.value = 'Up to date';
return;
}
_log.info('update available: ${m.version} (proto ${m.protocol})');
status.value = UpdateStatus(UpdatePhase.available,
manifest: m, canInstall: root != null);
lastCheck.value = 'Update v${m.version} available';
} catch (e) {
_log.warning('update check failed: $e');
lastCheck.value = 'Update check failed: $e';
}
}
void dismiss() {
final s = status.value;
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
!(s.manifest?.isRequired ?? false)) {
status.value = const UpdateStatus(UpdatePhase.idle);
}
}
Future<void> install() async {
final s = status.value;
final m = s.manifest;
final root = _root;
if (m == null || root == null) return;
if (s.phase != UpdatePhase.available && s.phase != UpdatePhase.failed) {
return;
}
status.value = UpdateStatus(UpdatePhase.downloading,
manifest: m, progress: 0, canInstall: true);
var lastPct = -1;
try {
await downloadAndApply(m, root,
executableName: installRoot == null ? _exeName : null,
onProgress: (got, total) {
if (total == null || total == 0) return;
final pct = got * 100 ~/ total;
if (pct == lastPct) return;
lastPct = pct;
status.value = UpdateStatus(UpdatePhase.downloading,
manifest: m, progress: got / total, canInstall: true);
if (pct == 100) {
status.value =
UpdateStatus(UpdatePhase.applying, manifest: m, canInstall: true);
}
}, say: (line) => _log.info(line));
} catch (e) {
_log.warning('update failed: $e');
status.value = UpdateStatus(UpdatePhase.failed,
manifest: m, error: '$e', canInstall: true);
return;
}
_log.info('updated to ${m.version}, restarting');
status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
await restart(_exe, launchArgs);
}
}