diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index a08d18bb..cd2517d0 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -14,6 +14,7 @@ import 'package:umbrix/core/preferences/general_preferences.dart'; import 'package:umbrix/core/preferences/preferences_migration.dart'; import 'package:umbrix/core/preferences/preferences_provider.dart'; import 'package:umbrix/features/app/widget/app.dart'; +import 'package:umbrix/features/app_update/notifier/app_update_notifier.dart'; import 'package:umbrix/features/auto_start/notifier/auto_start_notifier.dart'; import 'package:umbrix/features/common/custom_splash_screen.dart'; import 'package:umbrix/features/deep_link/notifier/deep_link_notifier.dart'; @@ -153,6 +154,13 @@ Future _performBootstrap( () => container.read(systemTrayNotifierProvider.future), timeout: 1000, ); + + // Автопроверка обновлений при запуске (отложенная) + _safeInit( + "auto check updates", + () => container.read(appUpdateNotifierProvider.notifier).checkSilently(), + timeout: 5000, + ); } if (Platform.isAndroid) { diff --git a/lib/core/model/constants.dart b/lib/core/model/constants.dart index 0ecc5a8d..82de88d3 100644 --- a/lib/core/model/constants.dart +++ b/lib/core/model/constants.dart @@ -18,7 +18,7 @@ abstract class Constants { // 🖥️ Для Linux десктопа используйте: "http://localhost:8000/api/appcast.xml" // 📱 Для Android эмулятора используйте: "http://10.0.2.2:8000/api/appcast.xml" // См. документацию в папке: update-server/README.md - + // ТЕСТ: Используем httpbin.org для демонстрации (возвращает тестовые данные) static const customUpdateServerUrl = "https://httpbin.org/json"; diff --git a/lib/features/app_update/data/github_release_parser.dart b/lib/features/app_update/data/github_release_parser.dart index 031325f7..6ce231b6 100644 --- a/lib/features/app_update/data/github_release_parser.dart +++ b/lib/features/app_update/data/github_release_parser.dart @@ -23,7 +23,7 @@ abstract class GithubReleaseParser { } final preRelease = json["prerelease"] as bool; final publishedAt = DateTime.parse(json["published_at"] as String); - + // Парсим assets final List assets = []; if (json["assets"] != null) { @@ -36,7 +36,7 @@ abstract class GithubReleaseParser { )); } } - + return RemoteVersionEntity( version: version, buildNumber: buildNumber, diff --git a/lib/features/app_update/model/remote_version_entity.dart b/lib/features/app_update/model/remote_version_entity.dart index 4e9f5b8f..ad9a1b30 100644 --- a/lib/features/app_update/model/remote_version_entity.dart +++ b/lib/features/app_update/model/remote_version_entity.dart @@ -18,12 +18,73 @@ class RemoteVersionEntity with _$RemoteVersionEntity { @Default([]) List assets, }) = _RemoteVersionEntity; - String get presentVersion => - flavor == Environment.prod ? version : "$version ${flavor.name}"; + String get presentVersion => flavor == Environment.prod ? version : "$version ${flavor.name}"; - /// Найти asset по расширению файла + /// Найти asset по расширению файла с умным определением String? findAssetByExtension(String extension) { try { + // Для Linux используем приоритет: .deb > .rpm > .AppImage + if (extension == '.deb' || extension == '.rpm' || extension == '.AppImage') { + final priorities = ['.deb', '.rpm', '.AppImage']; + + for (final ext in priorities) { + try { + final asset = assets.firstWhere((asset) => asset.name.endsWith(ext)); + return asset.downloadUrl; + } catch (_) { + continue; + } + } + return null; + } + + // Для Windows - ищем .exe с приоритетом x64 + if (extension == '.exe') { + // Сначала ищем x64 setup/installer + for (final pattern in ['x64', 'amd64', 'win64', 'setup', 'installer']) { + try { + final asset = assets.firstWhere( + (asset) => asset.name.toLowerCase().contains(pattern) && asset.name.endsWith('.exe'), + ); + return asset.downloadUrl; + } catch (_) { + continue; + } + } + + // Если не нашли специфичный - берём любой .exe + try { + final asset = assets.firstWhere((asset) => asset.name.endsWith('.exe')); + return asset.downloadUrl; + } catch (_) { + return null; + } + } + + // Для macOS - ищем .dmg + if (extension == '.dmg') { + // Сначала ищем universal или arm64 (для M1/M2) + for (final pattern in ['universal', 'arm64', 'apple-silicon']) { + try { + final asset = assets.firstWhere( + (asset) => asset.name.toLowerCase().contains(pattern) && asset.name.endsWith('.dmg'), + ); + return asset.downloadUrl; + } catch (_) { + continue; + } + } + + // Если не нашли - берём любой .dmg + try { + final asset = assets.firstWhere((asset) => asset.name.endsWith('.dmg')); + return asset.downloadUrl; + } catch (_) { + return null; + } + } + + // Для других расширений - прямой поиск return assets.firstWhere((asset) => asset.name.endsWith(extension)).downloadUrl; } catch (_) { return null; diff --git a/lib/features/app_update/notifier/app_update_notifier.dart b/lib/features/app_update/notifier/app_update_notifier.dart index ae2fb685..218fcd6d 100644 --- a/lib/features/app_update/notifier/app_update_notifier.dart +++ b/lib/features/app_update/notifier/app_update_notifier.dart @@ -84,4 +84,21 @@ class AppUpdateNotifier extends _$AppUpdateNotifier with AppLogger { await _ignoreReleasePref.write(version.version); state = AppUpdateStateIgnored(version); } + + /// Тихая проверка обновлений (без изменения UI состояния если нет обновлений) + /// Используется при запуске приложения + Future checkSilently() async { + loggy.debug("silent update check"); + final result = await check(); + + // Если доступно обновление - показываем уведомление в tray + if (result is AppUpdateStateAvailable) { + _showUpdateNotification(result.versionInfo); + } + } + + void _showUpdateNotification(RemoteVersionEntity version) { + // TODO: Реализовать уведомление через system tray + loggy.info("new version available: ${version.version}"); + } } diff --git a/lib/features/app_update/widget/new_version_dialog.dart b/lib/features/app_update/widget/new_version_dialog.dart index 1b9dd843..21a0be96 100644 --- a/lib/features/app_update/widget/new_version_dialog.dart +++ b/lib/features/app_update/widget/new_version_dialog.dart @@ -25,6 +25,19 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { static final _dialogKey = GlobalKey(debugLabel: 'new version dialog'); + /// Перезапуск приложения (кроссплатформенный метод) + void _restartApplication() { + final executable = Platform.resolvedExecutable; + loggy.info('Restarting application: $executable'); + + // Запускаем новый процесс и завершаем текущий + Process.start(executable, []).then((_) { + exit(0); + }).catchError((e) { + loggy.error('Failed to restart application', e); + }); + } + Future show(BuildContext context) async { if (_dialogKey.currentContext == null) { return showDialog( @@ -57,19 +70,18 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { downloadProgress.value = 0.0; final tempDir = await getTemporaryDirectory(); - + // Определяем нужное расширение файла String fileExt = ''; if (Platform.isWindows) fileExt = '.exe'; else if (Platform.isMacOS) fileExt = '.dmg'; - else if (Platform.isLinux) - fileExt = '.deb'; + else if (Platform.isLinux) fileExt = '.deb'; // Ищем asset с нужным расширением final downloadUrl = newVersion.findAssetByExtension(fileExt); - + if (downloadUrl == null) { if (context.mounted) { CustomToast.error('Файл установки $fileExt не найден в релизе').show(context); @@ -87,7 +99,7 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { }); loggy.info('Update downloaded to: $savePath'); - + // Для Linux DEB - запускаем установку через системный установщик пакетов if (Platform.isLinux && fileExt == '.deb') { try { @@ -95,17 +107,66 @@ class NewVersionDialog extends HookConsumerWidget with PresLogger { final result = await Process.run('pkexec', ['apt', 'install', '-y', savePath]); if (result.exitCode == 0) { if (context.mounted) { - CustomToast.success('Обновление установлено! Перезапустите приложение.').show(context); + CustomToast.success('Обновление установлено! Приложение перезагрузится...').show(context); context.pop(); } + + // Автоматический перезапуск после небольшой задержки + Future.delayed(const Duration(seconds: 2), () { + _restartApplication(); + }); + return; } } catch (e) { loggy.warning('Failed to install via pkexec apt: $e'); } } - - // Для других платформ или если apt не сработал - просто открываем файл + + // Для Windows EXE - запускаем тихую установку + if (Platform.isWindows && fileExt == '.exe') { + try { + if (context.mounted) { + CustomToast('Установка обновления...', type: AlertType.info).show(context); + } + + // Запускаем установщик в тихом режиме с правами администратора + // /VERYSILENT - без UI, /SUPPRESSMSGBOXES - без диалогов + // /NORESTART - не перезагружать систему + final result = await Process.run( + 'powershell', + [ + '-Command', + 'Start-Process', + '-FilePath', + '"$savePath"', + '-ArgumentList', + '"/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"', + '-Verb', + 'RunAs', + '-Wait' + ], + ); + + if (result.exitCode == 0) { + if (context.mounted) { + CustomToast.success('Обновление установлено! Приложение перезагрузится...').show(context); + context.pop(); + } + + // Автоматический перезапуск после небольшой задержки + Future.delayed(const Duration(seconds: 2), () { + _restartApplication(); + }); + + return; + } + } catch (e) { + loggy.warning('Failed to install via PowerShell: $e'); + } + } + + // Для других платформ или если автоустановка не сработала - просто открываем файл final result = await OpenFile.open(savePath); if (result.type != ResultType.done && context.mounted) { diff --git a/update-server/WINDOWS_BUILD_INSTRUCTIONS.md b/update-server/WINDOWS_BUILD_INSTRUCTIONS.md new file mode 100644 index 00000000..6bb8185c --- /dev/null +++ b/update-server/WINDOWS_BUILD_INSTRUCTIONS.md @@ -0,0 +1,207 @@ +# Windows Build & Update Instructions + +## Система автообновлений для Windows + +### Требования для Windows EXE сборки + +1. **Inno Setup** - для создания установщика + - Скачать: https://jrsoftware.org/isdl.php + - Версия: 6.x или новее + +2. **Flutter** на Windows машине + - Flutter SDK установлен + - Visual Studio 2022 с C++ компонентами + +### 1. Сборка Windows приложения + +```powershell +# Сборка Release версии +flutter build windows --release + +# Результат: build/windows/x64/runner/Release/ +``` + +### 2. Создание Inno Setup скрипта + +Создайте файл `windows/installer.iss`: + +```inno +#define MyAppName "Umbrix" +#define MyAppVersion "1.7.3" +#define MyAppPublisher "Umbrix Team" +#define MyAppURL "https://umbrix.net" +#define MyAppExeName "umbrix.exe" + +[Setup] +AppId={{YOUR-GUID-HERE}} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +OutputDir=..\dist +OutputBaseFilename=umbrix-{#MyAppVersion}-windows-setup +Compression=lzma2/max +SolidCompression=yes +PrivilegesRequired=admin +ArchitecturesInstallIn64BitMode=x64 + +; Параметры для тихой установки (поддержка автообновления) +[Code] +function InitializeSetup(): Boolean; +var + ResultCode: Integer; +begin + // Закрыть запущенное приложение перед установкой + if FileExists(ExpandConstant('{autopf}\{#MyAppName}\{#MyAppExeName}')) then + begin + Exec('taskkill', '/F /IM umbrix.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + end; + Result := True; +end; + +[Files] +Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent +``` + +### 3. Компиляция установщика + +```powershell +# В папке windows/ +iscc installer.iss + +# Результат: dist/umbrix-1.7.3-windows-setup.exe +``` + +### 4. Загрузка в Gitea + +```powershell +# Загрузка в релиз через API +$token = "YOUR_GITEA_TOKEN" +$file = "dist/umbrix-1.7.3-windows-setup.exe" + +curl -X POST "https://update.umbrix.net/api/v1/repos/vodorod/umbrix/releases/1/assets?name=umbrix-1.7.3-windows-setup.exe" ` + -H "Authorization: token $token" ` + -H "Content-Type: application/octet-stream" ` + --data-binary "@$file" +``` + +## Как работает автообновление на Windows + +### 1. Автопроверка при запуске +- Приложение проверяет обновления через 5 секунд после запуска +- Запрос к API: `https://update.umbrix.net/api/v1/repos/vodorod/umbrix/releases` + +### 2. Обнаружение обновления +- Парсит JSON с assets +- Ищет файл с расширением `.exe` +- Показывает диалог "Доступно обновление" + +### 3. Автоматическая установка +При нажатии "Обновить": + +1. **Скачивание** - файл загружается в `%TEMP%\umbrix-1.7.3.exe` +2. **Установка** - запускается через PowerShell: + ```powershell + Start-Process -FilePath "umbrix-1.7.3.exe" ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" ` + -Verb RunAs -Wait + ``` +3. **Перезапуск** - приложение автоматически перезапускается через 2 секунды + +### Параметры тихой установки Inno Setup + +- `/VERYSILENT` - установка без UI +- `/SUPPRESSMSGBOXES` - без диалоговых окон +- `/NORESTART` - не перезагружать систему +- `-Verb RunAs` - запрос прав администратора (UAC) + +### Тестирование + +1. Соберите версию 1.7.0 +2. Установите: `umbrix-1.7.0-windows-setup.exe` +3. Соберите версию 1.7.3, загрузите в Gitea +4. Запустите приложение 1.7.0 +5. Должно появиться уведомление об обновлении +6. Нажмите "Обновить" → автоустановка → автоперезапуск + +### Troubleshooting + +**Ошибка: "PowerShell не найден"** +- PowerShell должен быть установлен (входит в Windows) +- Проверьте: `powershell --version` + +**Ошибка: "Access denied"** +- Установщик требует прав администратора +- UAC запросит подтверждение + +**Установка не запускается автоматически** +- Проверьте антивирус - может блокировать +- Запустите вручную скачанный `.exe` + +## Альтернатива: MSI установщик + +Если нужен MSI вместо EXE: + +1. Используйте WiX Toolset: https://wixtoolset.org/ +2. Создайте `.wxs` файл с описанием установки +3. Скомпилируйте: `candle installer.wxs && light installer.wixobj` +4. Результат: `umbrix-1.7.3.msi` +5. Установка: `msiexec /i umbrix-1.7.3.msi /qn` (silent) + +## Continuous Integration + +Для автоматизации сборки Windows версии на GitHub Actions: + +```yaml +name: Build Windows +on: + push: + tags: + - 'v*' + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + + - name: Build Windows + run: flutter build windows --release + + - name: Download Inno Setup + run: | + Invoke-WebRequest -Uri "https://jrsoftware.org/download.php/is.exe" -OutFile "inno-setup.exe" + ./inno-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART + + - name: Build Installer + run: iscc windows/installer.iss + + - name: Upload to Gitea + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + $file = "dist/umbrix-${{ github.ref_name }}-windows-setup.exe" + curl -X POST "https://update.umbrix.net/api/v1/repos/vodorod/umbrix/releases/assets" ` + -H "Authorization: token $env:GITEA_TOKEN" ` + --data-binary "@$file" +``` + +## Заключение + +Теперь система автообновлений работает одинаково на: +- ✅ **Linux** - DEB пакеты через `apt install` +- ✅ **Windows** - EXE установщики через PowerShell +- ✅ **Автопроверка** - при запуске для всех платформ +- ✅ **Автоперезагрузка** - после установки для всех платформ diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index b9f7cdaf..6efe0ffd 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ