This commit is contained in:
Alessandro Autiero
2024-06-02 15:12:42 +02:00
parent efb508bd0c
commit 5d89a603d7
63 changed files with 1146 additions and 1379 deletions

View File

@@ -20,3 +20,4 @@ const List<String> kCannotConnectErrors = [
"Network failure when attempting to check platform restrictions",
"UOnlineAccountCommon::ForceLogout"
];
const String kGameFinishedLine = "PlayersLeft: 1";

View File

@@ -1,2 +0,0 @@
const String kDefaultMatchmakerHost = "127.0.0.1";
const int kDefaultMatchmakerPort = 8080;

View File

@@ -0,0 +1,42 @@
import 'dart:io';
import 'dart:isolate';
import 'package:path/path.dart' as path;
import 'package:reboot_common/common.dart';
extension FortniteVersionExtension on FortniteVersion {
static File? findExecutable(Directory directory, String name) {
try{
final result = directory.listSync(recursive: true)
.firstWhere((element) => path.basename(element.path) == name);
return File(result.path);
}catch(_){
return null;
}
}
File? get gameExecutable => findExecutable(location, "FortniteClient-Win64-Shipping.exe");
Future<File?> get headlessGameExecutable async {
final result = findExecutable(location, "FortniteClient-Win64-Shipping-Headless.exe");
if(result != null) {
return result;
}
final original = findExecutable(location, "FortniteClient-Win64-Shipping.exe");
if(original == null) {
return null;
}
final output = File("${original.parent.path}\\FortniteClient-Win64-Shipping-Headless.exe");
await original.copy(output.path);
await Isolate.run(() => patchHeadless(output));
return output;
}
File? get launcherExecutable => findExecutable(location, "FortniteLauncher.exe");
File? get eacExecutable => findExecutable(location, "FortniteClient-Win64-Shipping_EAC.exe");
File? get splashBitmap => findExecutable(location, "Splash.bmp");
}

View File

@@ -0,0 +1,8 @@
import 'dart:convert';
import 'dart:io';
extension ProcessExtension on Process {
Stream<String> get stdOutput => this.stdout.expand((event) => utf8.decode(event).split("\n"));
Stream<String> get stdError => this.stderr.expand((event) => utf8.decode(event).split("\n"));
}

View File

@@ -0,0 +1,15 @@
extension StringExtension on String {
bool get isBlank {
if(isEmpty) {
return true;
}
for(var char in this.split("")) {
if(char != " ") {
return false;
}
}
return true;
}
}

View File

@@ -5,14 +5,12 @@ class FortniteBuild {
final String identifier;
final String version;
final String link;
final FortniteBuildSource source;
FortniteBuild({required this.identifier, required this.version, required this.link, required this.source});
}
enum FortniteBuildSource {
manifest,
archive
FortniteBuild({
required this.identifier,
required this.version,
required this.link
});
}
class FortniteBuildDownloadProgress {

View File

@@ -8,6 +8,7 @@ class GameInstance {
final int? eacPid;
bool hosting;
bool launched;
bool movedToVirtualDesktop;
bool tokenError;
GameInstance? child;
@@ -18,7 +19,7 @@ class GameInstance {
required this.eacPid,
required this.hosting,
required this.child
}): tokenError = false, launched = false;
}): tokenError = false, launched = false, movedToVirtualDesktop = false;
void kill() {
Process.killPid(gamePid, ProcessSignal.sigabrt);
@@ -42,4 +43,4 @@ class GameInstance {
return false;
}
}
}

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:ini/ini.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_common/src/extension/types.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_proxy/shelf_proxy.dart';
import 'package:sync/semaphore.dart';
@@ -33,8 +35,8 @@ Future<bool> freeBackendPort() async {
}
Future<Uri?> pingBackend(String host, int port, [bool https=false]) async {
var hostName = _getHostName(host);
var declaredScheme = _getScheme(host);
var hostName = host.replaceFirst("http://", "").replaceFirst("https://", "");
var declaredScheme = host.startsWith("http://") ? "http" : host.startsWith("https://") ? "https" : null;
try{
var uri = Uri(
scheme: declaredScheme ?? (https ? "https" : "http"),
@@ -52,10 +54,6 @@ Future<Uri?> pingBackend(String host, int port, [bool https=false]) async {
}
}
String? _getHostName(String host) => host.replaceFirst("http://", "").replaceFirst("https://", "");
String? _getScheme(String host) => host.startsWith("http://") ? "http" : host.startsWith("https://") ? "https" : null;
Stream<String?> watchMatchmakingIp() async* {
if(!matchmakerConfigFile.existsSync()){
return;
@@ -110,56 +108,4 @@ Future<void> writeMatchmakingIp(String text) async {
config.set("GameServer", "ip", ip);
config.set("GameServer", "port", port);
await matchmakerConfigFile.writeAsString(config.toString(), flush: true);
}
Future<bool> isMatchmakerPortFree() async => await pingMatchmaker(kDefaultMatchmakerHost, kDefaultMatchmakerPort) == null;
Future<bool> freeMatchmakerPort() async {
await killProcessByPort(kDefaultMatchmakerPort);
final standardResult = await isMatchmakerPortFree();
if(standardResult) {
return true;
}
return false;
}
Future<Uri?> pingMatchmaker(String host, int port, [bool wss=false]) async {
var hostName = _getHostName(host);
var declaredScheme = _getScheme(host);
try{
var uri = Uri(
scheme: declaredScheme ?? (wss ? "wss" : "ws"),
host: hostName,
port: port
);
var completer = Completer<bool>();
var socket = await WebSocket.connect(uri.toString());
socket.listen(
(data) {
if(!completer.isCompleted) {
completer.complete(true);
}
},
onError: (error) {
if(!completer.isCompleted) {
completer.complete(false);
}
},
onDone: () {
if(!completer.isCompleted) {
completer.complete(false);
}
},
);
var result = await completer.future;
await socket.close();
return result ? uri : null;
}catch(_){
return wss || declaredScheme != null || isLocalHost(host) ? null : await pingMatchmaker(host, port, true);
}
}
String? _getHostName(String host) => host.replaceFirst("ws://", "").replaceFirst("wss://", "");
String? _getScheme(String host) => host.startsWith("ws://") ? "ws" : host.startsWith("wss://") ? "wss" : null;
}

View File

@@ -8,6 +8,7 @@ import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:path/path.dart' as path;
import 'package:reboot_common/common.dart';
import 'package:reboot_common/src/extension/types.dart';
const String kStopBuildDownloadSignal = "kill";
@@ -23,61 +24,14 @@ Dio _buildDioInstance() {
return dio;
}
final String _archiveSourceUrl = "https://raw.githubusercontent.com/simplyblk/Fortnitebuilds/main/README.md";
final String _archiveSourceUrl = "http://185.203.216.3/versions.json";
final RegExp _rarProgressRegex = RegExp("^((100)|(\\d{1,2}(.\\d*)?))%\$");
const String _manifestSourceUrl = "http://manifest.simplyblk.xyz";
const String _deniedConnectionError = "The connection was denied: your firewall might be blocking the download";
const String _unavailableError = "The build downloader is not available right now";
const String _genericError = "The build downloader is not working correctly";
const int _maxErrors = 100;
Future<List<FortniteBuild>> fetchBuilds(ignored) async {
(_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () =>
HttpClient()
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
final results = await Future.wait([_fetchManifestBuilds(), _fetchArchiveBuilds()]);
final data = <FortniteBuild>[];
for(final result in results) {
data.addAll(result);
}
return data;
}
Future<List<FortniteBuild>> _fetchManifestBuilds() async {
try {
final response = await _dio.get<String>(
"$_manifestSourceUrl/versions.json",
options: Options(
headers: {
"Accept-Encoding": "*",
"Cookie": "_c_t_c=1"
}
)
);
final body = response.data;
return jsonDecode(body!).map((version) {
final nameParts = version.split("-");
if(nameParts.length < 2) {
return null;
}
final name = nameParts[1];
return FortniteBuild(
identifier: name,
version: "Fortnite ${name}",
link: "$_manifestSourceUrl/$name/$name.manifest",
source: FortniteBuildSource.manifest
);
}).whereType<FortniteBuild>().toList();
}catch(_) {
return [];
}
}
Future<List<FortniteBuild>> _fetchArchiveBuilds() async {
final response = await _dio.get<String>(
_archiveSourceUrl,
options: Options(
@@ -88,32 +42,15 @@ Future<List<FortniteBuild>> _fetchArchiveBuilds() async {
return [];
}
final data = jsonDecode(response.data ?? "{}");
var results = <FortniteBuild>[];
for (var line in response.data?.split("\n") ?? []) {
if(!line.startsWith("|")) {
continue;
}
var parts = line.substring(1, line.length - 1).split("|");
if(parts.isEmpty) {
continue;
}
var link = parts.last.trim();
if(!link.endsWith(".zip") && !link.endsWith(".rar")) {
continue;
}
var version = parts.first.trim();
version = version.substring(0, version.indexOf("-"));
for(final entry in data.entries) {
results.add(FortniteBuild(
identifier: version,
version: "Fortnite $version",
link: link,
source: FortniteBuildSource.archive
identifier: entry.key,
version: "${entry.value["title"]} (${entry.key})",
link: entry.value["url"]
));
}
return results;
}
@@ -121,107 +58,23 @@ Future<List<FortniteBuild>> _fetchArchiveBuilds() async {
Future<void> downloadArchiveBuild(FortniteBuildDownloadOptions options) async {
try {
final stopped = _setupLifecycle(options);
switch(options.build.source) {
case FortniteBuildSource.archive:
final outputDir = Directory("${options.destination.path}\\.build");
await outputDir.create(recursive: true);
final fileName = options.build.link.substring(options.build.link.lastIndexOf("/") + 1);
final extension = path.extension(fileName);
final tempFile = File("${outputDir.path}\\$fileName");
if(await tempFile.exists()) {
await tempFile.delete(recursive: true);
}
final startTime = DateTime.now().millisecondsSinceEpoch;
final response = _downloadArchive(options, tempFile, startTime);
await Future.any([stopped.future, response]);
if(!stopped.isCompleted) {
await _extractArchive(stopped, extension, tempFile, options);
}
delete(outputDir);
break;
case FortniteBuildSource.manifest:
final response = await _dio.get<String>(
options.build.link,
options: Options(
headers: {
"Cookie": "_c_t_c=1"
}
)
);
final manifest = FortniteBuildManifestFile.fromJson(jsonDecode(response.data!));
final totalBytes = manifest.size;
final outputDir = options.destination;
await outputDir.create(recursive: true);
final startTime = DateTime.now().millisecondsSinceEpoch;
final codec = GZipCodec();
var completedBytes = 0;
var lastPercentage = 0.0;
final writers = manifest.chunks.map((chunkedFile) async {
final outputFile = File('${outputDir.path}/${chunkedFile.file}');
if(outputFile.existsSync()) {
if(outputFile.lengthSync() != chunkedFile.fileSize) {
await outputFile.delete();
} else {
completedBytes += chunkedFile.fileSize;
final percentage = completedBytes * 100 / totalBytes;
if(percentage - lastPercentage > 0.1) {
_onProgress(
startTime,
DateTime.now().millisecondsSinceEpoch,
percentage,
false,
options
);
}
return;
}
}
await outputFile.parent.create(recursive: true);
for(final chunkId in chunkedFile.chunksIds) {
final response = await _dio.get<Uint8List>(
"$_manifestSourceUrl/${options.build.identifier}/$chunkId.chunk",
options: Options(
responseType: ResponseType.bytes,
headers: {
"Accept-Encoding": "gzip",
"Cookie": "_c_t_c=1"
}
),
);
var responseBody = response.data;
if(responseBody == null) {
continue;
}
final decodedBody = codec.decode(responseBody);
await outputFile.writeAsBytes(
decodedBody,
mode: FileMode.append,
flush: true
);
completedBytes += decodedBody.length;
final percentage = completedBytes * 100 / totalBytes;
if(percentage - lastPercentage > 0.1) {
_onProgress(
startTime,
DateTime.now().millisecondsSinceEpoch,
percentage,
false,
options
);
}
}
});
await Future.any([stopped.future, Future.wait(writers)]);
break;
final outputDir = Directory("${options.destination.path}\\.build");
await outputDir.create(recursive: true);
final fileName = options.build.link.substring(options.build.link.lastIndexOf("/") + 1);
final extension = path.extension(fileName);
final tempFile = File("${outputDir.path}\\$fileName");
if(await tempFile.exists()) {
await tempFile.delete(recursive: true);
}
final startTime = DateTime.now().millisecondsSinceEpoch;
final response = _downloadArchive(options, tempFile, startTime);
await Future.any([stopped.future, response]);
if(!stopped.isCompleted) {
await _extractArchive(stopped, extension, tempFile, options);
}
delete(outputDir);
}catch(error) {
_onError(error, options);
}
@@ -358,7 +211,7 @@ Future<void> _extractArchive(Completer<dynamic> stopped, String extension, File
throw ArgumentError("Unexpected file extension: $extension}");
}
await Future.any([stopped.future, watchProcess(process.pid)]);
await Future.any([stopped.future, process.exitCode]);
}
void _onProgress(int startTime, int? now, double percentage, bool extracting, FortniteBuildDownloadOptions options) {
@@ -385,7 +238,6 @@ void _onError(Object? error, FortniteBuildDownloadOptions options) {
}
}
Completer<dynamic> _setupLifecycle(FortniteBuildDownloadOptions options) {
var stopped = Completer();
var lifecyclePort = ReceivePort();

View File

@@ -29,8 +29,8 @@ Future<bool> _patch(File file, Uint8List original, Uint8List patched) async {
throw Exception("Cannot mutate length of binary file");
}
var read = await file.readAsBytes();
var length = await file.length();
final read = await file.readAsBytes();
final length = await file.length();
var readOffset = 0;
var patchOffset = -1;
var patchCount = 0;
@@ -50,7 +50,6 @@ Future<bool> _patch(File file, Uint8List original, Uint8List patched) async {
readOffset++;
}
print("Offset: $patchOffset");
if(patchOffset == -1) {
return false;
}

View File

@@ -39,41 +39,4 @@ Future<bool> delete(FileSystemEntity file) async {
}
});
}
}
extension FortniteVersionExtension on FortniteVersion {
static File? findExecutable(Directory directory, String name) {
try{
var result = directory.listSync(recursive: true)
.firstWhere((element) => path.basename(element.path) == name);
return File(result.path);
}catch(_){
return null;
}
}
File? get gameExecutable => findExecutable(location, "FortniteClient-Win64-Shipping.exe");
Future<File?> get headlessGameExecutable async {
var result = findExecutable(location, "FortniteClient-Win64-Shipping-Headless.exe");
if(result != null) {
return result;
}
var original = findExecutable(location, "FortniteClient-Win64-Shipping.exe");
if(original == null) {
return null;
}
var output = File("${original.parent.path}\\FortniteClient-Win64-Shipping-Headless.exe");
await original.copy(output.path);
await Isolate.run(() => patchHeadless(output));
return output;
}
File? get launcherExecutable => findExecutable(location, "FortniteLauncher.exe");
File? get eacExecutable => findExecutable(location, "FortniteClient-Win64-Shipping_EAC.exe");
File? get splashBitmap => findExecutable(location, "Splash.bmp");
}

View File

@@ -10,6 +10,7 @@ import 'package:path/path.dart' as path;
import 'package:ffi/ffi.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_common/src/extension/process.dart';
import 'package:sync/semaphore.dart';
import 'package:win32/win32.dart';
@@ -282,10 +283,4 @@ class _ExtendedProcess extends Process {
return err;
}
}
extension ProcessExtension on Process {
Stream<String> get stdOutput => this.stdout.expand((event) => utf8.decode(event).split("\n"));
Stream<String> get stdError => this.stderr.expand((event) => utf8.decode(event).split("\n"));
}