diff --git a/.github/workflows/CI_windows.yml b/.github/workflows/CI_windows.yml index a2218ec..b2e6450 100644 --- a/.github/workflows/CI_windows.yml +++ b/.github/workflows/CI_windows.yml @@ -41,7 +41,9 @@ jobs: run: flutter pub get - name: Build Release - run: flutter build windows --release + run: | + flutter build windows --release + dart compile exe lib\version\updaterCli.dart -o build\windows\x64\runner\Release\data\flutter_assets\assets\bins\updater.exe - name: Copy dlls run: | diff --git a/lib/main.dart b/lib/main.dart index 138d15b..412f769 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -20,6 +21,7 @@ import 'utils/assetDirFinder.dart'; import 'utils/fileOpenCommand.dart'; import 'utils/loggingWrapper.dart'; import 'utils/utils.dart'; +import 'version/updateRestartData.dart'; import 'widgets/EditorLayout.dart'; import 'widgets/misc/ChangeNotifierWidget.dart'; import 'widgets/misc/mousePosition.dart'; @@ -99,7 +101,22 @@ void init(List args) async { runApp(const MyApp()); unawaited(waitForNextFrame().then((_) async { + if (args.length >= 2) { + if (args[0] == "--update-data") { + var b64Json = args[1]; + var updateDataJson = jsonDecode(utf8.decode(base64Decode(b64Json))); + var updateData = UpdateRestartData.fromJson(updateDataJson); + for (var file in updateData.openedHierarchyFiles) { + await openHierarchyManager.openFile(file); + } + for (var file in updateData.openedFiles) { + areasManager.openFile(file); + } + } + } for (var arg in args) { + if (!await File(arg).exists() && !await Directory(arg).exists()) + continue; await openHierarchyManager.openFile(arg); if (await canOpenAsFile(arg)) areasManager.openFile(arg); diff --git a/lib/stateManagement/openFiles/openFileTypes.dart b/lib/stateManagement/openFiles/openFileTypes.dart index 8534863..ff25bd6 100644 --- a/lib/stateManagement/openFiles/openFileTypes.dart +++ b/lib/stateManagement/openFiles/openFileTypes.dart @@ -12,6 +12,7 @@ import '../../utils/utils.dart'; import '../../widgets/filesView/FileType.dart'; import '../hasUuid.dart'; import '../miscValues.dart'; +import '../preferencesData.dart'; import '../undoable.dart'; import 'types/BnkFilePlaylistData.dart'; import 'types/BxmFileData.dart'; @@ -97,6 +98,8 @@ abstract class OpenFileData with HasUuid, Undoable, Disposable, HasUndoHistory { return WtaWtpData(name, path, secondaryName: secondaryName, isWtb: path.endsWith(".wtb")); else if (path.endsWith(".est") || path.endsWith(".sst")) return EstFileData(name, path, secondaryName: secondaryName); + else if (path == "preferences") + return PreferencesData(); else return TextFileData(name, path, secondaryName: secondaryName); } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 466c80d..c370ab0 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -638,7 +638,8 @@ void revealFileInExplorer(String path) { } const datExtensions = { ".dat", ".dtt", ".evn", ".eff", ".eft" }; -const bxmExtensions = { ".bxm", ".gad", ".sar", ".seq" }; +const bxmExtensions = { ".bxm", ".gad", ".sar", ".seq", ".vca", ".sab" }; +const datSubExtractDir = "nier2blender_extracted"; bool strEndsWithDat(String str) { for (var ext in datExtensions) { diff --git a/lib/version/installRelease.dart b/lib/version/installRelease.dart new file mode 100644 index 0000000..091f7e0 --- /dev/null +++ b/lib/version/installRelease.dart @@ -0,0 +1,129 @@ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:http/http.dart' as http; + +import '../stateManagement/events/statusInfo.dart'; +import '../stateManagement/hierarchy/FileHierarchy.dart'; +import '../stateManagement/hierarchy/HierarchyEntryTypes.dart'; +import '../stateManagement/openFiles/openFilesManager.dart'; +import '../utils/assetDirFinder.dart'; +import '../utils/utils.dart'; +import 'retrieveReleases.dart'; +import 'updateRestartData.dart'; + +Future installRelease(GitHubReleaseInfo release, StreamController updateStepStream, StreamController updateProgressStream) async { + updateStepStream.add("Preparing update..."); + updateProgressStream.add(0); + var downloadName = basename(release.downloadUrl); + var appDir = dirname(Platform.resolvedExecutable); + var updateDir = join(appDir, "_update"); + var updateDirCache = join(updateDir, "cache"); + var updateDirTemp = join(updateDir, "temp"); + String errorMessage = ""; + IOSink? updateFile_; + try { + if (await Directory(updateDirTemp).exists()) { + errorMessage = "Failed to delete temp update directory"; + await Directory(updateDirTemp).delete(recursive: true); + } + errorMessage = "Failed to create update directory"; + await Directory(updateDir).create(recursive: true); + await Directory(updateDirCache).create(); + await Directory(updateDirTemp).create(); + + errorMessage = "7z.exe or dll not found"; + var exe7z = await _get7zExe(updateDirCache); + errorMessage = "updater.exe not found"; + var exeUpdater = await _getUpdaterExe(updateDirTemp); + + var updateFilePath = join(updateDirCache, downloadName); + + var needsDownload = !await File(updateFilePath).exists() || await File(updateFilePath).length() != release.downloadSize; + if (needsDownload) { + var updateFile = File(updateFilePath).openWrite(); + updateFile_ = updateFile; + errorMessage = "Failed to download $downloadName"; + updateStepStream.add("Downloading $downloadName..."); + var response = await http.Client().send(http.Request("GET", Uri.parse(release.downloadUrl))); + var totalBytes = response.contentLength ?? release.downloadSize; + var receivedBytes = 0; + var downloadCompleter = Completer(); + response.stream.listen( + (chunk) { + updateFile.add(chunk); + receivedBytes += chunk.length; + updateProgressStream.add(receivedBytes / totalBytes); + }, + onDone: () async { + downloadCompleter.complete(); + }, + onError: (e) { + downloadCompleter.completeError(e); + }, + ); + await downloadCompleter.future; + await updateFile_.close(); + updateFile_ = null; + } + + errorMessage = "Failed to extract $downloadName"; + updateStepStream.add("Extracting $downloadName..."); + var extractDir = join(updateDirTemp, basenameWithoutExtension(release.downloadUrl)); + await Directory(extractDir).create(); + var result = await Process.run(exe7z, ["x", "-y", "-o$extractDir", updateFilePath]); + if (result.exitCode != 0) { + throw Exception("7z failed with exit code ${result.exitCode} and message: ${result.stderr}"); + } + await Future.delayed(const Duration(milliseconds: 250)); + + var backupDir = join(updateDirTemp, "backup"); + await Directory(backupDir).create(); + + var openFiles = areasManager.areas + .map((e) => e.files) + .expand((e) => e) + .map((e) => e.path) + .toList(); + var openHierarchies = openHierarchyManager.children + .whereType() + .map((e) => e.path).toList(); + var restartData = UpdateRestartData(openFiles, openHierarchies); + var restartDataJson = restartData.toJson(); + var restartDataJsonString = base64Encode(utf8.encode(jsonEncode(restartDataJson))); + + var logFilePath = join(updateDirTemp, "update.log"); + await Process.start( + "$exeUpdater --app-dir $appDir --extracted-dir $extractDir --backup-dir $backupDir --exe-path ${Platform.resolvedExecutable} --restart-data $restartDataJsonString > $logFilePath 2>&1", + [], + runInShell: true, + mode: ProcessStartMode.detachedWithStdio, + ); + } catch (e, st) { + messageLog.add("$errorMessage: $e\n$st"); + showToast(errorMessage); + throw Exception(errorMessage); + } finally { + await updateFile_?.close(); + } +} + +Future _get7zExe(String cacheDir) async { + var src7zExe = join(assetsDir!, "bins", "7z.exe"); + var src7zDll = join(assetsDir!, "bins", "7z.dll"); + var dst7zExe = join(cacheDir, "7z.exe"); + var dst7zDll = join(cacheDir, "7z.dll"); + await File(src7zExe).copy(dst7zExe); + await File(src7zDll).copy(dst7zDll); + return dst7zExe; +} + +Future _getUpdaterExe(String tempDir) async { + var srcUpdaterExe = join(assetsDir!, "bins", "updater.exe"); + var dstUpdaterExe = join(tempDir, "updater.exe"); + await File(srcUpdaterExe).copy(dstUpdaterExe); + return dstUpdaterExe; +} diff --git a/lib/version/retrieveReleases.dart b/lib/version/retrieveReleases.dart new file mode 100644 index 0000000..94e9594 --- /dev/null +++ b/lib/version/retrieveReleases.dart @@ -0,0 +1,49 @@ + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'version.dart'; + +const _apiEndpoint = "https://api.github.com/repos/ArthurHeitmann/F-SERVO/releases"; + +class GitHubReleaseInfo { + final String tagName; + final String url; + final DateTime publishedAt; + final String downloadUrl; + final int downloadSize; + FServoVersion? _version; + FServoVersion? get version => _version ??= FServoVersion.parse(tagName); + + GitHubReleaseInfo(this.tagName, this.url, this.publishedAt, this.downloadUrl, this.downloadSize); +} + +Future> retrieveReleases() async { + var response = await http.get(Uri.parse(_apiEndpoint)); + if (response.statusCode != 200) + throw Exception("Failed to retrieve releases: ${response.body}"); + var json = jsonDecode(response.body); + var releases = (json as List) + .map((e) { + return GitHubReleaseInfo( + e["tag_name"], + e["html_url"], + DateTime.parse(e["published_at"]), + (e["assets"] as List).firstOrNull?["browser_download_url"], + (e["assets"] as List).firstOrNull?["size"], + ); + }) + .where((e) => e.version != null) + .toList(); + releases.sort((a, b) => b.version!.compareTo(a.version!)); + return releases; +} + +GitHubReleaseInfo? updateRelease(List releases, FServoVersion currentVersion) { + return releases + .where((e) => e.version != null) + .where((e) => e.version!.branch == currentVersion.branch) + .where((e) => e.version! > currentVersion) + .firstOrNull; +} diff --git a/lib/version/updateRestartData.dart b/lib/version/updateRestartData.dart new file mode 100644 index 0000000..55a258c --- /dev/null +++ b/lib/version/updateRestartData.dart @@ -0,0 +1,19 @@ + +class UpdateRestartData { + final List openedFiles; + final List openedHierarchyFiles; + + const UpdateRestartData(this.openedFiles, this.openedHierarchyFiles); + + Map toJson() => { + "openedFiles": openedFiles, + "openedHierarchyFiles": openedHierarchyFiles, + }; + + factory UpdateRestartData.fromJson(Map json) { + return UpdateRestartData( + List.from(json["openedFiles"]), + List.from(json["openedHierarchyFiles"]), + ); + } +} diff --git a/lib/version/updaterCli.dart b/lib/version/updaterCli.dart new file mode 100644 index 0000000..f317865 --- /dev/null +++ b/lib/version/updaterCli.dart @@ -0,0 +1,109 @@ + +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart'; + +void main(List args) async { + var parser = ArgParser(); + parser.addOption("app-dir", mandatory: true); + parser.addOption("extracted-dir", mandatory: true); + parser.addOption("backup-dir", mandatory: true); + parser.addOption("exe-path", mandatory: true); + parser.addOption("restart-data", mandatory: true); + var results = parser.parse(args); + var appDir = results.option("app-dir")!; + var extractedDir = results.option("extracted-dir")!; + var backupDir = results.option("backup-dir")!; + var exePath = results.option("exe-path")!; + var restartData = results.option("restart-data")!; + + const deletableExtensions = [".dll", ".exe"]; + const dataFolderName = "data"; + List oldFilesToMove = Directory(appDir).listSync() + .where((e) => e is File && deletableExtensions.contains(extension(e.path))) + .toList(); + oldFilesToMove.add(Directory(join(appDir, dataFolderName))); + + var newFiles = Directory(extractedDir).listSync(); + + print("Killing F-SERVO.exe..."); + Process.runSync("taskkill", ["/F", "/IM", "F-SERVO.exe"]); + + List movedOldFiles = []; + List movedNewFiles = []; + try { + print("Moving files to backup directory..."); + for (var file in oldFilesToMove) { + var newPath = join(backupDir, basename(file.path)); + print("Moving ${file.path} to $newPath"); + await _tryRename(file, newPath); + movedOldFiles.add(file); + } + print("Moving extracted files to app directory..."); + for (var file in newFiles) { + var newPath = join(appDir, basename(file.path)); + print("Moving ${file.path} to $newPath"); + await _tryRename(file, newPath); + movedNewFiles.add(file); + } + } catch (e) { + try { + print("Error moving files, recovering..."); + print("Deleting new files..."); + for (var file in movedNewFiles) { + print("Deleting ${file.path}"); + await _tryDelete(file); + } + print("Moving old files back..."); + for (var file in movedOldFiles) { + var newPath = join(appDir, basename(file.path)); + print("Moving ${file.path} back to $newPath"); + await _tryRename(file, newPath); + } + print("Recovery complete"); + } catch (e, st) { + print("Error moving files back: $e\n$st"); + } + print("Update failed. Press enter to exit."); + stdin.readLineSync(); + rethrow; + } + + print("Restarting F-SERVO.exe..."); + var result = await Process.start(exePath, ["--update-data", restartData], mode: ProcessStartMode.detached); + await result.exitCode; + + print("Update complete"); +} + +const _maxAttempts = 5; +Future _tryRename(FileSystemEntity file, String path, [int attempt = 1]) async { + try { + await file.rename(path); + } catch (e) { + if (attempt < _maxAttempts) { + await Future.delayed(Duration(milliseconds: attempt * 100)); + await _tryRename(file, path, attempt + 1); + } else { + rethrow; + } + } +} + +Future _tryDelete(FileSystemEntity file, [int attempt = 1]) async { + try { + if (file is Directory) { + await file.delete(recursive: true); + } else { + await file.delete(); + } + } catch (e) { + if (attempt < _maxAttempts) { + await Future.delayed(Duration(milliseconds: attempt * 100)); + await _tryDelete(file, attempt + 1); + } else { + rethrow; + } + } +} diff --git a/lib/version/version.dart b/lib/version/version.dart new file mode 100644 index 0000000..696cf49 --- /dev/null +++ b/lib/version/version.dart @@ -0,0 +1,98 @@ + +const _masterBranch = "master"; + +class FServoVersion { + final int major; + final int minor; + final int patch; + final String branch; + + const FServoVersion(this.major, this.minor, this.patch, this.branch); + + static FServoVersion? parse(String version) { + if (version.startsWith("v")) + version = version.substring(1); + var mainBranch = version.split("-"); + if (mainBranch.length > 2) + return null; + var branch = mainBranch.length == 2 ? mainBranch[1] : _masterBranch; + var main = mainBranch[0].split("."); + if (main.length < 3) + return null; + var major = int.tryParse(main[0]); + var minor = int.tryParse(main[1]); + var patch = int.tryParse(main[2]); + if (major == null || minor == null || patch == null) + return null; + return FServoVersion(major, minor, patch, branch); + } + + @override + String toString() => "v$major.$minor.$patch-$branch"; + + String toUiString(FServoVersion currentVersion) { + var str = toString(); + if (this == currentVersion) + str += " (current)"; + else if (this > currentVersion) + str += " (new)"; + return str; + } + + @override + bool operator ==(Object other) { + if (other is FServoVersion) { + return major == other.major && minor == other.minor && patch == other.patch && branch == other.branch; + } + return false; + } + + @override + int get hashCode => Object.hash(major, minor, patch, branch); + + bool operator <(FServoVersion other) { + if (major < other.major) + return true; + if (major > other.major) + return false; + if (minor < other.minor) + return true; + if (minor > other.minor) + return false; + return patch < other.patch; + } + + bool operator >(FServoVersion other) { + if (major > other.major) + return true; + if (major < other.major) + return false; + if (minor > other.minor) + return true; + if (minor < other.minor) + return false; + return patch > other.patch; + } + + int compareTo(FServoVersion other) { + if (this < other) + return -1; + if (this > other) + return 1; + return 0; + } +} + +const version = FServoVersion(1, 4, 9, _masterBranch); + +const branches = [_masterBranch, "mgrr"]; + +const branchToGameName = { + _masterBranch: "NieR: Automata", + "mgrr": "MGR: Revengeance", +}; + +const branchFirstVersionedRelease = { + _masterBranch: FServoVersion(1, 4, 9, _masterBranch), + "mgrr": FServoVersion(1, 4, 13, "mgrr"), +}; diff --git a/lib/widgets/misc/preferencesEditor.dart b/lib/widgets/misc/preferencesEditor.dart index 5b6b0ed..7539249 100644 --- a/lib/widgets/misc/preferencesEditor.dart +++ b/lib/widgets/misc/preferencesEditor.dart @@ -15,6 +15,7 @@ import 'ChangeNotifierWidget.dart'; import 'RowSeparated.dart'; import 'SmoothScrollBuilder.dart'; import 'smallButton.dart'; +import 'updater.dart'; class PreferencesEditor extends ChangeNotifierWidget { final PreferencesData prefs; @@ -43,6 +44,7 @@ class _PreferencesEditorState extends ChangeNotifierState { ...makeIndexingEditor(), ...makeThemeEditor(), ...makeMusicEditor(), + ...makeUpdater(), ], ), ), @@ -308,4 +310,13 @@ class _PreferencesEditorState extends ChangeNotifierState { ), ]; } + + List makeUpdater() { + return [ + const SizedBox(height: 40,), + const Text("Updates:", style: sectionHeaderStyle,), + const SizedBox(height: 10,), + const VersionUpdaterUi(), + ]; + } } diff --git a/lib/widgets/misc/updater.dart b/lib/widgets/misc/updater.dart new file mode 100644 index 0000000..dc50e44 --- /dev/null +++ b/lib/widgets/misc/updater.dart @@ -0,0 +1,178 @@ + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../utils/utils.dart'; +import '../../version/installRelease.dart'; +import '../../version/retrieveReleases.dart'; +import '../../version/version.dart'; +import '../theme/customTheme.dart'; + +class VersionUpdaterUi extends StatefulWidget { + const VersionUpdaterUi({super.key}); + + @override + State createState() => _VersionUpdaterUiState(); +} + +class _VersionUpdaterUiState extends State { + List? releases; + Future? releasesFuture; + String selectedBranch = version.branch; + GitHubReleaseInfo? selectedRelease; + static StreamController updateStepStream = StreamController.broadcast(); + static StreamController updateProgressStream = StreamController.broadcast(); + static bool isUpdating = false; + static String? errorMessage; + + void loadReleases() { + releasesFuture = retrieveReleases() + .then((value) { + releases = value; + selectedRelease = releases! + .where((e) => e.version == version) + .firstOrNull; + setState(() {}); + }) + .catchError((e) { + releases = null; + selectedRelease = null; + showToast("Failed to load GitHub releases"); + print("Failed to load GitHub releases: $e"); + setState(() {}); + }); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text("Game: "), + PopupMenuButton( + initialValue: selectedBranch, + onSelected: (v) { + setState(() => selectedBranch = v); + }, + itemBuilder: (context) => branches.map((branch) => PopupMenuItem( + value: branch, + height: 20, + child: Text(branchToGameName[branch] ?? branch), + )).toList(), + position: PopupMenuPosition.under, + constraints: BoxConstraints.tightFor(width: 190), + popUpAnimationStyle: AnimationStyle(duration: Duration.zero), + tooltip: "", + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + branchToGameName[selectedBranch] ?? selectedBranch, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ], + ), + Row( + children: [ + const Text("Version: "), + FutureBuilder( + future: releasesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done || snapshot.hasError) { + return TextButton( + onPressed: snapshot.connectionState == ConnectionState.waiting ? null : loadReleases, + child: const Text("Load versions"), + ); + } + return PopupMenuButton( + initialValue: selectedRelease, + onSelected: (v) { + setState(() => selectedRelease = v); + }, + itemBuilder: (context) => releases!.where((r) => r.version?.branch == selectedBranch).map((release) => PopupMenuItem( + value: release, + height: 20, + child: Text(release.version!.toUiString(version)), + )).toList(), + position: PopupMenuPosition.under, + constraints: BoxConstraints(maxWidth: 250, maxHeight: 200), + popUpAnimationStyle: AnimationStyle(duration: Duration.zero), + tooltip: "", + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + selectedRelease?.version.toString() ?? version.toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ); + } + ), + ], + ), + if (selectedRelease != null && selectedRelease!.version! < branchFirstVersionedRelease[selectedRelease!.version!.branch]!) + Text("Warning: Selected version does not have a built in updater", style: TextStyle(color: getTheme(context).titleBarButtonCloseColor)), + if (selectedRelease != null && selectedRelease!.version != version) + TextButton( + onPressed: isUpdating ? null : () { + isUpdating = true; + errorMessage = null; + installRelease(selectedRelease!, updateStepStream, updateProgressStream) + .catchError((e) { + errorMessage = e.toString(); + setState(() {}); + }) + .whenComplete(() { + isUpdating = false; + setState(() {}); + }); + setState(() {}); + }, + child: Text("Update to ${selectedRelease!.version}"), + ), + const SizedBox(height: 16), + if (isUpdating) + StreamBuilder( + stream: updateStepStream.stream, + builder: (context, stepSnapshot) { + return StreamBuilder( + stream: updateProgressStream.stream, + builder: (context, progressSnapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + height: 5, + child: LinearProgressIndicator( + value: progressSnapshot.data ?? 0, + backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.25), + ), + ), + const SizedBox(height: 8), + Text(stepSnapshot.data ?? "Updating..."), + ], + ); + } + ); + } + ), + if (errorMessage != null) + Text(errorMessage!, style: TextStyle(color: getTheme(context).titleBarButtonCloseColor)), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 77eb00b..46d6945 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "3.6.1" args: - dependency: transitive + dependency: "direct main" description: name: args sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 @@ -313,7 +313,7 @@ packages: source: hosted version: "0.7.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" diff --git a/pubspec.yaml b/pubspec.yaml index 799eae6..a147c1d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: archive: ^3.4.9 + args: ^2.6.0 audioplayers: ^6.1.0 code_text_field: ^1.1.0 convert: ^3.1.1 @@ -29,6 +30,7 @@ dependencies: flutter_window_close: ^1.0.0 fluttertoast: ^8.2.4 highlight: ^0.7.0 + http: ^1.2.2 image: ^4.1.3 mutex: ^3.0.0 path: ^1.8.3