From 9f4d59f13786630da7c20df302e681213f4fce31 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 13:01:58 +0200 Subject: [PATCH 1/6] feat(update): add OrionUpdateProvider to manage update flow and progress UI Introduce OrionUpdateProvider which exposes progress and message ValueNotifiers and presents a modal UpdateProgressOverlay during updates. Provider locates the Orion installation root (env, cwd/executable ancestors, common locations), downloads update assets into a temp workspace with streamed progress reporting, extracts into a new folder, generates and runs an install script that backs up the current install, moves the new files, fixes permissions and restarts the service. Includes a macOS simulation path and defensive cleanup/error logging. --- lib/main.dart | 5 + lib/util/providers/orion_update_provider.dart | 325 ++++++++++++++++++ test/orion_update_provider_test.dart | 53 +++ 3 files changed, 383 insertions(+) create mode 100644 lib/util/providers/orion_update_provider.dart create mode 100644 test/orion_update_provider_test.dart diff --git a/lib/main.dart b/lib/main.dart index 6254fda..64a902f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -52,6 +52,7 @@ import 'package:orion/util/providers/locale_provider.dart'; import 'package:orion/util/providers/theme_provider.dart'; import 'package:orion/util/error_handling/connection_error_watcher.dart'; import 'package:orion/util/error_handling/notification_watcher.dart'; +import 'package:orion/util/providers/orion_update_provider.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -172,6 +173,10 @@ class OrionRoot extends StatelessWidget { create: (_) => ManualProvider(), lazy: true, ), + ChangeNotifierProvider( + create: (_) => OrionUpdateProvider(), + lazy: true, + ), ], child: const OrionMainApp(), ); diff --git a/lib/util/providers/orion_update_provider.dart b/lib/util/providers/orion_update_provider.dart new file mode 100644 index 0000000..62f5fc3 --- /dev/null +++ b/lib/util/providers/orion_update_provider.dart @@ -0,0 +1,325 @@ +/* +* Orion - Orion Update Provider +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:orion/settings/update_progress.dart'; + +/// Provider that encapsulates the Orion update flow. +/// +/// Exposes [progress] and [message] ValueNotifiers that the UI can listen to +/// and a single [performUpdate] method to run the update flow. The provider +/// will show a modal update overlay while an update is in progress. + +class OrionUpdateProvider extends ChangeNotifier { + final Logger _logger = Logger('OrionUpdateProvider'); + + final ValueNotifier progress = ValueNotifier(0.0); + final ValueNotifier message = ValueNotifier(''); + bool _isDialogOpen = false; + + void _openUpdateDialog(BuildContext context, String initialMessage) { + message.value = initialMessage; + if (_isDialogOpen) return; + _isDialogOpen = true; + progress.value = 0.0; + + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext ctx) { + return UpdateProgressOverlay( + progress: progress, + message: message, + icon: PhosphorIcons.warningDiamond(), + ); + }, + ).then((_) { + _isDialogOpen = false; + progress.value = 0.0; + message.value = ''; + }); + } + + /// Locate the Orion installation root. Public for testing. + String findOrionRoot() { + final String localUser = Platform.environment['USER'] ?? 'pi'; + final String envRoot = Platform.environment['ORION_ROOT'] ?? ''; + if (envRoot.isNotEmpty) return envRoot; + + // Check current working directory and its ancestors first (pwd) + try { + Directory dir = Directory.current; + while (true) { + final candidate = dir.path; + if (File('$candidate/orion.cfg').existsSync() || + Directory('$candidate/orion').existsSync() || + candidate.endsWith('/orion')) { + return candidate; + } + if (dir.parent.path == dir.path) break; + dir = dir.parent; + } + } catch (_) {} + + // Check ancestors of the running executable (helps packaged installs) + try { + final exe = Platform.resolvedExecutable; + final exeDir = Directory(exe).parent; + Directory dir = exeDir; + while (true) { + final candidate = dir.path; + if (File('$candidate/orion.cfg').existsSync() || + Directory('$candidate/orion').existsSync() || + candidate.endsWith('/orion')) { + return candidate; + } + if (dir.parent.path == dir.path) break; // reached root + dir = dir.parent; + } + } catch (_) { + // ignore + } + + // Check a few common locations without preferring any single one + final candidates = [ + '/usr/local/share/orion', + '/usr/share/orion', + '/var/lib/orion', + '/opt/orion', + '${Platform.environment['HOME'] ?? '/home/$localUser'}/orion' + ]; + for (final c in candidates) { + try { + if (Directory(c).existsSync() || File('$c/orion.cfg').existsSync()) { + return c; + } + } catch (_) {} + } + + // Last resort: user's home + return '${Platform.environment['HOME'] ?? '/home/$localUser'}/orion'; + } + + void _dismissUpdateDialog(BuildContext context) { + if (_isDialogOpen && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + _isDialogOpen = false; + progress.value = 0.0; + message.value = ''; + } + } + + void _updateProgressAndText(String msg, {double step = 0.25}) { + message.value = msg; + if (_isDialogOpen) { + progress.value = min(1.0, progress.value + step); + } + } + + Future performUpdate(BuildContext context, String assetUrl) async { + final String localUser = Platform.environment['USER'] ?? 'pi'; + + final String orionRoot = findOrionRoot(); + + // Use a temp upgrade workspace to download & extract; this avoids + // writing into system install directories directly and is safer when + // the install root is read-only. + final tempDir = await Directory.systemTemp.createTemp('orion_upgrade_'); + final String upgradeFolder = '${tempDir.path}/'; + final String downloadPath = '$upgradeFolder/orion_armv7.tar.gz'; + final String orionFolder = + orionRoot.endsWith('/') ? orionRoot : '$orionRoot/'; + final String newOrionFolder = '${upgradeFolder}orion_new/'; + final String backupFolder = '${upgradeFolder}orion_backup/'; + final String scriptPath = '$upgradeFolder/update_orion.sh'; + + if (assetUrl.isEmpty) { + _logger.warning('Asset URL is empty'); + return; + } + + _logger.info('Downloading from $assetUrl'); + + // macOS dev simulation + if (Platform.isMacOS) { + _openUpdateDialog(context, 'Starting update...'); + final simSteps = [ + 'Downloading update file...', + 'Extracting update file...', + 'Executing update script...', + 'Finalizing update...' + ]; + + final int remaining = simSteps.length - 1; + final double perStep = remaining > 0 ? (1.0 / remaining) : 1.0; + + for (var i = 0; i < simSteps.length; i++) { + final m = simSteps[i]; + final step = i == 0 ? 0.0 : perStep; + _updateProgressAndText(m, step: step); + await Future.delayed(const Duration(seconds: 3)); + } + + progress.value = 1.0; + await Future.delayed(const Duration(seconds: 1)); + _dismissUpdateDialog(context); + return; + } + + // Normal flow: open dialog and perform streamed download, extract and run + _openUpdateDialog(context, 'Starting update...'); + + try { + final upgradeDir = Directory(upgradeFolder); + if (await upgradeDir.exists()) { + try { + await upgradeDir.delete(recursive: true); + } catch (e) { + _logger.warning('Could not purge upgrade directory'); + } + } + await upgradeDir.create(recursive: true); + + final newDir = Directory(newOrionFolder); + if (await newDir.exists()) { + try { + await newDir.delete(recursive: true); + } catch (e) { + _logger.warning('Could not purge new Orion directory'); + } + } + await newDir.create(recursive: true); + + _updateProgressAndText('Downloading update file...', step: 0.0); + await Future.delayed(const Duration(seconds: 1)); + + final client = http.Client(); + try { + final req = http.Request('GET', Uri.parse(assetUrl)); + final streamedResp = await client.send(req); + if (streamedResp.statusCode == 200) { + final int contentLength = streamedResp.contentLength ?? -1; + final file = File(downloadPath); + final sink = file.openWrite(); + int bytesReceived = 0; + + if (contentLength > 0) { + message.value = 'Downloading update file...'; + progress.value = 0.0; + await for (final chunk in streamedResp.stream) { + sink.add(chunk); + bytesReceived += chunk.length; + final double raw = bytesReceived / contentLength; + progress.value = min(1.0, raw * 0.25); + } + } else { + message.value = 'Downloading update file...'; + await for (final chunk in streamedResp.stream) { + sink.add(chunk); + } + } + + await sink.flush(); + await sink.close(); + + progress.value = max(progress.value, 0.25); + + _updateProgressAndText('Extracting update file...', step: 0.25); + await Future.delayed(const Duration(seconds: 1)); + } else { + _logger.warning( + 'Failed to download update file, status: ${streamedResp.statusCode}'); + } + } finally { + client.close(); + } + + final extractResult = await Process.run('sudo', + ['tar', '--overwrite', '-xzf', downloadPath, '-C', newOrionFolder]); + if (extractResult.exitCode != 0) { + _logger + .warning('Failed to extract update file: ${extractResult.stderr}'); + _dismissUpdateDialog(context); + return; + } + + final scriptContent = ''' +#!/bin/bash + +# Variables +local_user=$localUser +orion_folder=$orionFolder +new_orion_folder=$newOrionFolder +upgrade_folder=$upgradeFolder +backup_folder=$backupFolder + +# If previous backup exists, delete it +if [ -d \$backup_folder ]; then + sudo rm -R \$backup_folder +fi + +# Backup the current Orion directory +sudo cp -R \$orion_folder \$backup_folder + +# Remove the old Orion directory +sudo rm -R \$orion_folder + +# Restore config file +sudo cp \$backup_folder/orion.cfg \$new_orion_folder + +# Move the new Orion directory to the original location +sudo mv \$new_orion_folder \$orion_folder + +# Delete the upgrade and new folder +sudo rm -R \$upgrade_folder + +# Fix permissions +sudo chown -R \$local_user:\$local_user \$orion_folder + +# Restart the Orion service +sudo systemctl restart orion.service +'''; + + final scriptFile = File(scriptPath); + await scriptFile.writeAsString(scriptContent); + await Process.run('chmod', ['+x', scriptPath]); + + _updateProgressAndText('Executing update script...', step: 0.25); + + // Mark complete after executing script + progress.value = 1.0; + await Future.delayed(const Duration(seconds: 2)); + + final result = await Process.run('nohup', ['sudo', scriptPath]); + if (result.exitCode == 0) { + _logger.info('Update script executed successfully'); + } else { + _logger.warning('Failed to execute update script: ${result.stderr}'); + } + } catch (e) { + _logger.warning('Update failed: $e'); + } finally { + _dismissUpdateDialog(context); + } + } +} diff --git a/test/orion_update_provider_test.dart b/test/orion_update_provider_test.dart new file mode 100644 index 0000000..dbdfac2 --- /dev/null +++ b/test/orion_update_provider_test.dart @@ -0,0 +1,53 @@ +/* +* Orion - Orion Update Provider Test +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:orion/util/providers/orion_update_provider.dart'; + +void main() { + group('OrionUpdateProvider.findOrionRoot', () { + test('prefers ORION_ROOT env var', () { + // set env var temporarily by launching a subprocess is not trivial in tests, + // so we just test the method behavior assuming env var is not set. + // For a true env test, this should be run in an isolated process. + final p = OrionUpdateProvider(); + final root = p.findOrionRoot(); + expect(root, isNotNull); + }); + + test('detects current working directory as install', () async { + final tmp = await Directory.systemTemp.createTemp('orion_test_pwd_'); + final orionDir = Directory('${tmp.path}/orion'); + await orionDir.create(recursive: true); + final cfg = File('${tmp.path}/orion/orion.cfg'); + await cfg.writeAsString('test'); + + final old = Directory.current; + try { + Directory.current = tmp; + final p = OrionUpdateProvider(); + final root = p.findOrionRoot(); + expect(root.contains('orion'), isTrue); + } finally { + Directory.current = old; + await tmp.delete(recursive: true); + } + }); + }); +} From 8947f31007eca1ec6e1a70fbd9c325cc03133200 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 13:02:19 +0200 Subject: [PATCH 2/6] feat(update): add UpdateProgressOverlay modal for system updates Add a full-screen GlassApp overlay that displays update progress and messages. Includes a pulsing status icon, animated LinearProgressIndicator (driven by a ValueListenable with smooth tween animation), and a bottom message driven by a ValueListenable. --- lib/settings/update_progress.dart | 172 ++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 lib/settings/update_progress.dart diff --git a/lib/settings/update_progress.dart b/lib/settings/update_progress.dart new file mode 100644 index 0000000..6640a0f --- /dev/null +++ b/lib/settings/update_progress.dart @@ -0,0 +1,172 @@ +/* +* Orion - Update Progress Overlay +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:orion/glasser/glasser.dart'; + +/// A full-screen modal overlay that displays update progress and messages. +/// Designed to be shown during system updates to inform the user of progress +/// and to discourage turning off the machine during critical operations. +/// Implements [UpdateProgressOverlay]. + +class UpdateProgressOverlay extends StatefulWidget { + final ValueListenable progress; + final ValueListenable message; + final IconData icon; + + const UpdateProgressOverlay({ + super.key, + required this.progress, + required this.message, + this.icon = PhosphorIconsFill.warning, + }); + + @override + State createState() => _UpdateProgressOverlayState(); +} + +class _UpdateProgressOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulseController; + late final Animation _pulseAnim; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _pulseAnim = Tween(begin: 0.99, end: 1.03).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GlassApp( + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + // Centered icon with halo (matches startup screen logo treatment) + Center( + child: Transform.translate( + offset: const Offset(0, -30), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Pulsing icon + ScaleTransition( + scale: _pulseAnim, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + widget.icon, + size: 120, + color: Colors.redAccent, + ), + ), + ), + const SizedBox(height: 10), + Text( + 'UPDATE IN PROGRESS', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 30, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 5), + Text( + 'Please do not turn off the machine.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + + // Bottom message (matches positioning in startup_screen) + Positioned( + left: 20, + right: 20, + bottom: 30, + child: ValueListenableBuilder( + valueListenable: widget.message, + builder: (context, msg, _) => Text( + msg, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'AtkinsonHyperlegible', + fontSize: 24, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + + // Progress bar positioned above the message (smoothly animated) + Positioned( + left: 40, + right: 40, + bottom: 90, + child: ValueListenableBuilder( + valueListenable: widget.progress, + builder: (context, p, _) => SizedBox( + height: 14, + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: TweenAnimationBuilder( + tween: Tween(end: p.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 600), + curve: Curves.easeInOut, + builder: (context, animatedP, _) => + LinearProgressIndicator( + value: animatedP, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary), + backgroundColor: Colors.black.withValues(alpha: 0.3), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} From 405ec9e90823a60d29083ff82c3088e5f434a0f0 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 13:02:54 +0200 Subject: [PATCH 3/6] refactor(update): delegate update flow to OrionUpdateProvider and remove local updater - Add launchUpdateDialog confirmation UI that calls Provider.of.performUpdate(...) - Use configurable repo (overrideRepo) for GitHub API URLs instead of hardcoded owner - Wire update buttons to the new dialog, adjust button tints and update related UI text/TODO --- lib/settings/update_screen.dart | 279 +++++++++----------------------- 1 file changed, 78 insertions(+), 201 deletions(-) diff --git a/lib/settings/update_screen.dart b/lib/settings/update_screen.dart index 6c2363a..c750db6 100644 --- a/lib/settings/update_screen.dart +++ b/lib/settings/update_screen.dart @@ -18,7 +18,6 @@ // ignore_for_file: use_build_context_synchronously import 'dart:convert'; -import 'dart:io'; import 'dart:math'; import 'package:auto_size_text/auto_size_text.dart'; @@ -26,6 +25,8 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:orion/util/providers/orion_update_provider.dart'; import 'package:orion/glasser/glasser.dart'; import 'package:orion/pubspec.dart'; @@ -55,9 +56,11 @@ class UpdateScreenState extends State with SafeSetStateMixin { String _currentVersion = ''; String _release = 'BRANCH_dev'; String _assetUrl = ''; + String _repo = 'Open-Resin-Alliance'; final Logger _logger = Logger('UpdateScreen'); final OrionConfig _config = OrionConfig(); + // Update dialog state is handled by OrionUpdateProvider @override void initState() { @@ -68,6 +71,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { _betaUpdatesOverride = _config.getFlag('releaseOverride', category: 'developer'); _release = _config.getString('overrideRelease', category: 'developer'); + _repo = _config.getString('overrideRepo', category: 'developer'); _logger.info('Firmware spoofing enabled: $_isFirmwareSpoofingEnabled'); _logger.info('Beta updates override enabled: $_betaUpdatesOverride'); _logger.info('Release channel override: $_release'); @@ -93,8 +97,8 @@ class UpdateScreenState extends State with SafeSetStateMixin { if (_betaUpdatesOverride) { await _checkForBERUpdates(release); } else { - const String url = - 'https://api.github.com/repos/thecontrappostoshop/orion/releases/latest'; + final String url = + 'https://api.github.com/repos/$_repo/orion/releases/latest'; int retryCount = 0; const int maxRetries = 3; const int initialDelay = 750; @@ -169,8 +173,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { _logger.warning('release name is empty'); release = 'BRANCH_dev'; } - String url = - 'https://api.github.com/repos/thecontrappostoshop/orion/releases'; + String url = 'https://api.github.com/repos/$_repo/orion/releases'; int retryCount = 0; const int maxRetries = 3; const int initialDelay = 750; // Initial delay in milliseconds @@ -187,7 +190,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { final String latestVersion = releaseItem['tag_name']; final String commitSha = releaseItem['target_commitish']; final commitUrl = - 'https://api.github.com/repos/thecontrappostoshop/orion/commits/$commitSha'; + 'https://api.github.com/repos/$_repo/orion/commits/$commitSha'; final commitResponse = await http.get(Uri.parse(commitUrl)); if (commitResponse.statusCode == 200) { final commitJson = json.decode(commitResponse.body); @@ -325,6 +328,68 @@ class UpdateScreenState extends State with SafeSetStateMixin { ); } + Future launchUpdateDialog() async { + bool shouldUpdate = await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return GlassAlertDialog( + title: Row( + children: [ + PhosphorIcon( + PhosphorIcons.download(), + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + const SizedBox(width: 12), + Text( + 'Update Orion', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + content: const Text( + 'Do you want to update the Orion HMI?\nThis will download the latest version from GitHub.'), + actions: [ + GlassButton( + tint: GlassButtonTint.negative, + onPressed: () { + Navigator.of(context).pop(false); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 60), + ), + child: const Text( + 'Dismiss', + style: TextStyle(fontSize: 20), + ), + ), + GlassButton( + tint: GlassButtonTint.positive, + onPressed: () { + Navigator.of(context).pop(true); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 60), + ), + child: const Text( + 'Update Now', + style: TextStyle(fontSize: 20), + ), + ) + ], + ); + }, + ); + + if (shouldUpdate) { + final provider = Provider.of(context, listen: false); + await provider.performUpdate(context, _assetUrl); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -430,6 +495,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { children: [ Expanded( child: GlassButton( + tint: GlassButtonTint.neutral, onPressed: _viewChangelog, style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(65), @@ -465,8 +531,9 @@ class UpdateScreenState extends State with SafeSetStateMixin { width: 12), // Add some space between the buttons Expanded( child: GlassButton( + tint: GlassButtonTint.positive, onPressed: () async { - _performUpdate(context); + launchUpdateDialog(); }, style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(65), @@ -524,7 +591,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { ), ), ), - // TODO: Placeholder for Odyssey updater - pending API changes + // TODO: Placeholder for Backend / OS updater - pending API changes GlassCard( outlined: true, child: Padding( @@ -539,7 +606,7 @@ class UpdateScreenState extends State with SafeSetStateMixin { size: 30), const SizedBox(width: 10), const Text( - 'Odyssey Updater', + 'Backend Updater', style: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, @@ -558,196 +625,6 @@ class UpdateScreenState extends State with SafeSetStateMixin { ), ); } - - Future _performUpdate(BuildContext context) async { - final String localUser = Platform.environment['USER'] ?? 'pi'; - final String upgradeFolder = '/home/$localUser/orion_upgrade/'; - final String downloadPath = '$upgradeFolder/orion_armv7.tar.gz'; - final String orionFolder = '/home/$localUser/orion/'; - final String newOrionFolder = '/home/$localUser/orion_new/'; - final String backupFolder = '/home/$localUser/orion_backup/'; - final String scriptPath = '$upgradeFolder/update_orion.sh'; - - if (_assetUrl.isEmpty) { - _logger.warning('Asset URL is empty'); - return; - } - - _logger.info('Downloading from $_assetUrl'); - - // Show the update dialog - _showUpdateDialog(context, 'Starting update...'); - - try { - // Purge and recreate the upgrade folder - final upgradeDir = Directory(upgradeFolder); - if (await upgradeDir.exists()) { - try { - await upgradeDir.delete(recursive: true); - } catch (e) { - _logger.warning('Could not purge upgrade directory'); - } - } - await upgradeDir.create(recursive: true); - - final newDir = Directory(newOrionFolder); - if (await newDir.exists()) { - try { - await newDir.delete(recursive: true); - } catch (e) { - _logger.warning('Could not purge new Orion directory'); - } - } - await newDir.create(recursive: true); - - // Update dialog text - _updateDialogText(context, 'Downloading update file...'); - - await Future.delayed(const Duration(seconds: 1)); - - // Download the update file - final response = await http.get(Uri.parse(_assetUrl)); - if (response.statusCode == 200) { - final file = File(downloadPath); - await file.writeAsBytes(response.bodyBytes); - - // Update dialog text - _updateDialogText(context, 'Extracting update file...'); - - await Future.delayed(const Duration(seconds: 1)); - - // Extract the update to the new directory - final extractResult = await Process.run('sudo', - ['tar', '--overwrite', '-xzf', downloadPath, '-C', newOrionFolder]); - if (extractResult.exitCode != 0) { - _logger.warning( - 'Failed to extract update file: ${extractResult.stderr}'); - _dismissUpdateDialog(context); - return; - } - - // Create the update script - final scriptContent = ''' -#!/bin/bash - -# Variables -local_user=$localUser -orion_folder=$orionFolder -new_orion_folder=$newOrionFolder -upgrade_folder=$upgradeFolder -backup_folder=$backupFolder - -# If previous backup exists, delete it -if [ -d \$backup_folder ]; then - sudo rm -R \$backup_folder -fi - -# Backup the current Orion directory -sudo cp -R \$orion_folder \$backup_folder - -# Remove the old Orion directory -sudo rm -R \$orion_folder - -# Restore config file -sudo cp \$backup_folder/orion.cfg \$new_orion_folder - -# Move the new Orion directory to the original location -sudo mv \$new_orion_folder \$orion_folder - -# Delete the upgrade and new folder -sudo rm -R \$upgrade_folder - -# Fix permissions -sudo chown -R \$local_user:\$local_user \$orion_folder - -# Restart the Orion service -sudo systemctl restart orion.service -'''; - - final scriptFile = File(scriptPath); - await scriptFile.writeAsString(scriptContent); - await Process.run('chmod', ['+x', scriptPath]); - - // Update dialog text - _updateDialogText(context, 'Executing update script...'); - - await Future.delayed(const Duration(seconds: 2)); - - // Execute the update script - final result = await Process.run('nohup', ['sudo', scriptPath]); - if (result.exitCode == 0) { - _logger.info('Update script executed successfully'); - } else { - _logger.warning('Failed to execute update script: ${result.stderr}'); - } - } else { - _logger.warning('Failed to download update file'); - } - } catch (e) { - _logger.warning('Update failed: $e'); - } finally { - // Dismiss the update dialog - _dismissUpdateDialog(context); - } - } - - Future _showUpdateDialog(BuildContext context, String message) { - return showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return SafeArea( - child: Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.zero, - child: Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - color: Theme.of(context).colorScheme.surface, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - height: 75, - width: 75, - child: CircularProgressIndicator( - strokeWidth: 6, - ), - ), - const SizedBox(height: 60), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 32), - ), - ], - ), - ), - ), - ); - }, - ); - } - - void _updateDialogText(BuildContext context, String message) { - if (Navigator.of(context).canPop()) { - // Show the new dialog first - _showUpdateDialog(context, message).then((_) { - // Pop the old dialog after the new one has been rendered - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }); - } else { - // If there's no dialog to pop, just show the new one - _showUpdateDialog(context, message); - } - } - - void _dismissUpdateDialog(BuildContext context) { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - } } + // Update flow is handled by OrionUpdateProvider via + // `Provider.of(context, listen: false).performUpdate(...)`. From b0b0b5bb22cbdcd52a135323515ca1178579b982 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 19:57:21 +0200 Subject: [PATCH 4/6] fix(update): ensure force update installs work --- lib/settings/update_screen.dart | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/settings/update_screen.dart b/lib/settings/update_screen.dart index c750db6..f0a722f 100644 --- a/lib/settings/update_screen.dart +++ b/lib/settings/update_screen.dart @@ -94,6 +94,46 @@ class UpdateScreenState extends State with SafeSetStateMixin { } Future _checkForUpdates(String release) async { + if (_isFirmwareSpoofingEnabled) { + // Force update: always allow install, skip version check + final String url = + 'https://api.github.com/repos/$_repo/orion/releases/latest'; + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final jsonResponse = json.decode(response.body); + final String latestVersion = + jsonResponse['tag_name'].replaceAll('v', ''); + final String releaseNotes = jsonResponse['body']; + final String releaseDate = jsonResponse['published_at']; + // Find the asset URL for orion_armv7.tar.gz + final asset = jsonResponse['assets'].firstWhere( + (asset) => asset['name'] == 'orion_armv7.tar.gz', + orElse: () => null); + final String assetUrl = + asset != null ? asset['browser_download_url'] : ''; + safeSetState(() { + _latestVersion = latestVersion; + _releaseNotes = releaseNotes; + _releaseDate = releaseDate; + _isLoading = false; + _isUpdateAvailable = true; + _assetUrl = assetUrl; + }); + } else { + safeSetState(() { + _logger.warning('Failed to fetch updates'); + _isLoading = false; + }); + } + } catch (e) { + _logger.warning(e.toString()); + safeSetState(() { + _isLoading = false; + }); + } + return; + } if (_betaUpdatesOverride) { await _checkForBERUpdates(release); } else { @@ -200,7 +240,10 @@ class UpdateScreenState extends State with SafeSetStateMixin { final String commitDate = commitJson['commit']['committer'] ['date']; // Fetch commit date - if (isCurrentCommitUpToDate(shortCommitSha)) { + if (_isFirmwareSpoofingEnabled) { + // Force update: always allow install, skip version check + _logger.info('Force update enabled, skipping version check.'); + } else if (isCurrentCommitUpToDate(shortCommitSha)) { _logger.info( 'Current version is up-to-date with the latest pre-release.'); safeSetState(() { From d107692f4de9b91b2221cb8f4430b8615e3ae01e Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 20:20:49 +0200 Subject: [PATCH 5/6] fix(update): honor overrideRepo/default, handle branch overrides, and robustly parse version/commit/date - Use trimmed overrideRepo with fallback to 'Open-Resin-Alliance' and log chosen repo. - When firmware spoofing (force update) and a non-default release is set, fetch that release's pre-release info. - Make commit/version parsing safer (guard when + not present) and improve UI strings to handle timestamps or missing build metadata. --- lib/settings/update_screen.dart | 39 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/settings/update_screen.dart b/lib/settings/update_screen.dart index f0a722f..413d4f3 100644 --- a/lib/settings/update_screen.dart +++ b/lib/settings/update_screen.dart @@ -71,10 +71,15 @@ class UpdateScreenState extends State with SafeSetStateMixin { _betaUpdatesOverride = _config.getFlag('releaseOverride', category: 'developer'); _release = _config.getString('overrideRelease', category: 'developer'); - _repo = _config.getString('overrideRepo', category: 'developer'); + final repoOverride = + _config.getString('overrideRepo', category: 'developer'); + _repo = repoOverride.trim().isNotEmpty + ? repoOverride.trim() + : 'Open-Resin-Alliance'; _logger.info('Firmware spoofing enabled: $_isFirmwareSpoofingEnabled'); _logger.info('Beta updates override enabled: $_betaUpdatesOverride'); _logger.info('Release channel override: $_release'); + _logger.info('Repo: $_repo'); } Future _initUpdateCheck() async { @@ -96,6 +101,11 @@ class UpdateScreenState extends State with SafeSetStateMixin { Future _checkForUpdates(String release) async { if (_isFirmwareSpoofingEnabled) { // Force update: always allow install, skip version check + // Respect branch override: fetch releases for the selected branch + if (release.isNotEmpty && release != 'BRANCH_dev') { + await _checkForBERUpdates(release); + return; + } final String url = 'https://api.github.com/repos/$_repo/orion/releases/latest'; try { @@ -202,10 +212,12 @@ class UpdateScreenState extends State with SafeSetStateMixin { } bool isCurrentCommitUpToDate(String commitSha) { - _logger.info('Current commit SHA: ${_currentVersion.split('+')[1]}'); + final parts = _currentVersion.split('+'); + final currentCommit = parts.length > 1 ? parts[1] : ''; + _logger.info('Current commit SHA: $currentCommit'); _logger.info('Latest commit SHA: $commitSha'); if (_isFirmwareSpoofingEnabled) return false; - return commitSha == _currentVersion.split('+')[1]; + return commitSha == currentCommit; } Future _checkForBERUpdates(String release) async { @@ -519,17 +531,22 @@ class UpdateScreenState extends State with SafeSetStateMixin { ), const Divider(), Text( - _betaUpdatesOverride - ? _preRelease - ? 'Latest Commit: $_latestVersion' - : 'Rollback to: ${_latestVersion.split('(')[1].split(')')[0]}' - : 'Latest Version: ${_latestVersion.split('+')[0]}', - style: const TextStyle(fontSize: 22)), + _betaUpdatesOverride + ? 'Latest Commit: $_latestVersion' + : (_latestVersion.contains('+') + ? 'Latest Version: ${_latestVersion.split('+')[0]}' + : 'Latest Version: $_latestVersion'), + style: const TextStyle(fontSize: 22), + ), const SizedBox(height: 10), Text( _betaUpdatesOverride - ? 'Commit Date: ${_commitDate.split('T')[0]}' // Display commit date if beta updates are enabled - : 'Release Date: ${_releaseDate.split('T')[0]}', + ? (_commitDate.contains('T') + ? 'Commit Date: ${_commitDate.split('T')[0]}' + : 'Commit Date: $_commitDate') + : (_releaseDate.contains('T') + ? 'Release Date: ${_releaseDate.split('T')[0]}' + : 'Release Date: $_releaseDate'), style: const TextStyle(fontSize: 20, color: Colors.grey), ), const SizedBox(height: 10), From 13e608e968036f9f4149fdfaeafe6c0ec441a6c0 Mon Sep 17 00:00:00 2001 From: PaulGD03 Date: Tue, 21 Oct 2025 23:28:41 +0200 Subject: [PATCH 6/6] hotfix(update): validate and normalize install target; make update script defensive - a previous version of this code will possibly wipe your home directory, if orion is installed there. --- lib/util/providers/orion_update_provider.dart | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/util/providers/orion_update_provider.dart b/lib/util/providers/orion_update_provider.dart index 62f5fc3..736017d 100644 --- a/lib/util/providers/orion_update_provider.dart +++ b/lib/util/providers/orion_update_provider.dart @@ -263,12 +263,61 @@ class OrionUpdateProvider extends ChangeNotifier { return; } - final scriptContent = ''' -#!/bin/bash + // Normalize the target install directory to ensure we operate on an + // `orion` subfolder rather than a user's home directory. + String installDir; + if (orionFolder.endsWith('/orion') || orionFolder.endsWith('/orion/')) { + installDir = orionFolder.endsWith('/') ? orionFolder : '$orionFolder/'; + } else { + installDir = '${orionFolder}orion/'; + } + // Ensure trailing slash + if (!installDir.endsWith('/')) installDir = '$installDir/'; + + // Safety checks: never operate directly on the user's home, root, or + // other obvious dangerous targets. + final dangerous = [ + '/', + '/home', + '/home/', + '/home/$localUser', + '/home/$localUser/', + '/root', + '/root/' + ]; + if (dangerous.contains(installDir) || installDir.trim().isEmpty) { + _logger.severe( + 'Refusing to run update: computed installDir looks unsafe: $installDir'); + _dismissUpdateDialog(context); + return; + } + + // Verify the install directory actually exists and looks like an Orion + // install (contains orion.cfg or a directory structure). If it doesn't, + // abort to avoid operating at a higher level. + try { + final checkDir = Directory(installDir); + final hasCfg = File('${installDir}orion.cfg').existsSync(); + final hasDir = checkDir.existsSync(); + if (!hasCfg && !hasDir) { + _logger.severe( + 'Refusing to run update: installDir does not look like an Orion install: $installDir'); + _dismissUpdateDialog(context); + return; + } + } catch (e) { + _logger.severe('Error while verifying installDir: $e'); + _dismissUpdateDialog(context); + return; + } + + // Use the safe installDir in the script so we never rm -R the user's + // home directory by accident. + final scriptContent = '''#!/bin/bash # Variables local_user=$localUser -orion_folder=$orionFolder +orion_folder=$installDir new_orion_folder=$newOrionFolder upgrade_folder=$upgradeFolder backup_folder=$backupFolder @@ -278,26 +327,46 @@ if [ -d \$backup_folder ]; then sudo rm -R \$backup_folder fi -# Backup the current Orion directory -sudo cp -R \$orion_folder \$backup_folder +# Backup the current Orion directory (safe-targeted) +if [ -d "\$orion_folder" ]; then + sudo cp -R "\$orion_folder" "\$backup_folder" +else + # Fallback: try to copy orion subdir + if [ -d "${installDir}orion" ]; then + sudo cp -R "${installDir}orion" "\$backup_folder" + orion_folder="${installDir}orion" + fi +fi -# Remove the old Orion directory -sudo rm -R \$orion_folder +# Remove the old Orion directory (targeted) +if [ -d "\$orion_folder" ]; then + sudo rm -R "\$orion_folder" +fi -# Restore config file -sudo cp \$backup_folder/orion.cfg \$new_orion_folder +# Restore config file if present +if [ -f "\$backup_folder/orion.cfg" ] && [ -d "\$new_orion_folder" ]; then + sudo cp "\$backup_folder/orion.cfg" "\$new_orion_folder" +fi # Move the new Orion directory to the original location -sudo mv \$new_orion_folder \$orion_folder +if [ -d "\$new_orion_folder" ]; then + sudo mv "\$new_orion_folder" "\$orion_folder" +fi # Delete the upgrade and new folder -sudo rm -R \$upgrade_folder +if [ -d "\$upgrade_folder" ]; then + sudo rm -R "\$upgrade_folder" +fi # Fix permissions -sudo chown -R \$local_user:\$local_user \$orion_folder +if [ -d "\$orion_folder" ]; then + sudo chown -R "\$local_user":"\$local_user" "\$orion_folder" +fi -# Restart the Orion service -sudo systemctl restart orion.service +# Restart the Orion service if available +if command -v systemctl >/dev/null 2>&1; then + sudo systemctl restart orion.service || true +fi '''; final scriptFile = File(scriptPath);