Reboot v3

This commit is contained in:
Alessandro Autiero
2023-05-24 23:19:36 +02:00
parent 760e336bc0
commit eb6381912c
129 changed files with 396379 additions and 1380 deletions

View File

@@ -0,0 +1,26 @@
import 'package:get/get.dart';
import 'package:reboot_launcher/src/model/fortnite_build.dart';
class BuildController extends GetxController {
List<FortniteBuild>? builds;
FortniteBuild? _selectedBuild;
final List<Function()> _listeners;
late RxBool cancelledDownload;
BuildController() : _listeners = [] {
cancelledDownload = RxBool(false);
}
FortniteBuild get selectedBuild => _selectedBuild ?? builds!.elementAt(0);
set selectedBuild(FortniteBuild build) {
_selectedBuild = build;
for (var listener in _listeners) {
listener();
}
}
void addOnBuildChangedListener(Function() listener) => _listeners.add(listener);
void removeOnBuildChangedListener() => _listeners.clear();
}

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import 'package:reboot_launcher/src/model/game_instance.dart';
import '../../model/update_status.dart';
const String kDefaultPlayerName = "Player";
class GameController extends GetxController {
late final GetStorage _storage;
late final TextEditingController username;
late final TextEditingController password;
late final RxBool showPassword;
late final TextEditingController customLaunchArgs;
late final Rx<List<FortniteVersion>> versions;
late final Rxn<FortniteVersion> _selectedVersion;
late final RxBool started;
late final Rx<UpdateStatus> updateStatus;
GameInstance? instance;
GameController() {
_storage = GetStorage("reboot_game");
Iterable decodedVersionsJson = jsonDecode(_storage.read("versions") ?? "[]");
var decodedVersions = decodedVersionsJson
.map((entry) => FortniteVersion.fromJson(entry))
.toList();
versions = Rx(decodedVersions);
versions.listen((data) => _saveVersions());
var decodedSelectedVersionName = _storage.read("version");
var decodedSelectedVersion = decodedVersions.firstWhereOrNull(
(element) => element.name == decodedSelectedVersionName);
_selectedVersion = Rxn(decodedSelectedVersion);
username = TextEditingController(text: _storage.read("username") ?? kDefaultPlayerName);
username.addListener(() => _storage.write("username", username.text));
password = TextEditingController(text: _storage.read("password") ?? "");
password.addListener(() => _storage.write("password", password.text));
showPassword = RxBool(false);
customLaunchArgs = TextEditingController(text: _storage.read("custom_launch_args" ?? ""));
customLaunchArgs.addListener(() => _storage.write("custom_launch_args", customLaunchArgs.text));
started = RxBool(false);
updateStatus = Rx(UpdateStatus.waiting);
}
FortniteVersion? getVersionByName(String name) {
return versions.value.firstWhereOrNull((element) => element.name == name);
}
void addVersion(FortniteVersion version) {
var empty = versions.value.isEmpty;
versions.update((val) => val?.add(version));
if(empty){
selectedVersion = version;
}
}
FortniteVersion removeVersionByName(String versionName) {
var version = versions.value.firstWhere((element) => element.name == versionName);
removeVersion(version);
return version;
}
void removeVersion(FortniteVersion version) {
versions.update((val) => val?.remove(version));
}
Future<void> _saveVersions() async {
var serialized = jsonEncode(versions.value.map((entry) => entry.toJson()).toList());
await _storage.write("versions", serialized);
}
bool get hasVersions => versions.value.isNotEmpty;
bool get hasNoVersions => versions.value.isEmpty;
FortniteVersion? get selectedVersion => _selectedVersion();
set selectedVersion(FortniteVersion? version) {
_selectedVersion(version);
_storage.write("version", version?.name);
}
void updateVersion(FortniteVersion version, Function(FortniteVersion) function) {
versions.update((val) => function(version));
}
}

View File

@@ -0,0 +1,28 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../model/game_instance.dart';
const String kDefaultServerName = "Reboot Game Server";
class HostingController extends GetxController {
late final GetStorage _storage;
late final TextEditingController name;
late final TextEditingController category;
late final RxBool discoverable;
late final RxBool started;
GameInstance? instance;
HostingController() {
_storage = GetStorage("reboot_hosting");
name = TextEditingController(text: _storage.read("name") ?? kDefaultServerName);
name.addListener(() => _storage.write("name", name.text));
category = TextEditingController(text: _storage.read("category") ?? "");
category.addListener(() => _storage.write("category", category.text));
discoverable = RxBool(_storage.read("discoverable") ?? false);
discoverable.listen((value) => _storage.write("discoverable", value));
started = RxBool(false);
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:jaguar/jaguar.dart';
import '../../model/server_type.dart';
import '../../util/server.dart';
class ServerController extends GetxController {
static const String _serverName = "127.0.0.1";
static const String _serverPort = "3551";
late final GetStorage _storage;
late final TextEditingController host;
late final TextEditingController port;
late final Rx<ServerType> type;
late final RxBool warning;
late RxBool started;
late RxBool detached;
HttpServer? remoteServer;
ServerController() {
_storage = GetStorage("reboot_server");
started = RxBool(false);
type = Rx(ServerType.values.elementAt(_storage.read("type") ?? 0));
type.listen((value) {
host.text = _readHost();
port.text = _readPort();
_storage.write("type", value.index);
if(!started.value) {
return;
}
stop();
});
host = TextEditingController(text: _readHost());
host.addListener(() => _storage.write("${type.value.id}_host", host.text));
port = TextEditingController(text: _readPort());
port.addListener(() => _storage.write("${type.value.id}_port", port.text));
warning = RxBool(_storage.read("lawin_value") ?? true);
warning.listen((value) => _storage.write("lawin_value", value));
detached = RxBool(_storage.read("detached") ?? false);
warning.listen((value) => _storage.write("detached", value));
}
String _readHost() {
String? value = _storage.read("${type.value.id}_host");
return value != null && value.isNotEmpty ? value
: type.value != ServerType.remote ? _serverName : "";
}
String _readPort() {
return _storage.read("${type.value.id}_port") ?? _serverPort;
}
Future<bool> stop() async {
started.value = false;
try{
switch(type()){
case ServerType.embedded:
stopServer();
break;
case ServerType.remote:
await remoteServer?.close(force: true);
remoteServer = null;
break;
case ServerType.local:
break;
}
return true;
}catch(_){
started.value = true;
return false;
}
}
}

View File

@@ -0,0 +1,79 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/server.dart';
import 'dart:ui';
import '../../util/reboot.dart';
class SettingsController extends GetxController {
static const String _kDefaultIp = "127.0.0.1";
static const bool _kDefaultAutoUpdate = true;
late final GetStorage _storage;
late final String originalDll;
late final TextEditingController updateUrl;
late final TextEditingController rebootDll;
late final TextEditingController consoleDll;
late final TextEditingController authDll;
late final TextEditingController matchmakingIp;
late final RxBool autoUpdate;
late double width;
late double height;
late double? offsetX;
late double? offsetY;
late double scrollingDistance;
SettingsController() {
_storage = GetStorage("reboot_settings");
updateUrl = TextEditingController(text: _storage.read("update_url") ?? rebootDownloadUrl);
updateUrl.addListener(() => _storage.write("update_url", updateUrl.text));
rebootDll = _createController("reboot", "reboot.dll");
consoleDll = _createController("console", "console.dll");
authDll = _createController("cobalt", "cobalt.dll");
matchmakingIp = TextEditingController(text: _storage.read("ip") ?? _kDefaultIp);
matchmakingIp.addListener(() async {
var text = matchmakingIp.text;
_storage.write("ip", text);
writeMatchmakingIp(text);
});
width = _storage.read("width") ?? kDefaultWindowWidth;
height = _storage.read("height") ?? kDefaultWindowHeight;
offsetX = _storage.read("offset_x");
offsetY = _storage.read("offset_y");
autoUpdate = RxBool(_storage.read("auto_update") ?? _kDefaultAutoUpdate);
autoUpdate.listen((value) async => _storage.write("auto_update", value));
scrollingDistance = 0.0;
}
TextEditingController _createController(String key, String name) {
var controller = TextEditingController(text: _storage.read(key) ?? _controllerDefaultPath(name));
controller.addListener(() => _storage.write(key, controller.text));
return controller;
}
void saveWindowSize() {
_storage.write("width", window.physicalSize.width);
_storage.write("height", window.physicalSize.height);
}
void saveWindowOffset(Offset position) {
_storage.write("offset_x", position.dx);
_storage.write("offset_y", position.dy);
}
void reset(){
updateUrl.text = rebootDownloadUrl;
rebootDll.text = _controllerDefaultPath("reboot.dll");
consoleDll.text = _controllerDefaultPath("console.dll");
authDll.text = _controllerDefaultPath("cobalt.dll");
matchmakingIp.text = _kDefaultIp;
writeMatchmakingIp(_kDefaultIp);
autoUpdate.value = _kDefaultAutoUpdate;
}
String _controllerDefaultPath(String name) => "${assetsDirectory.path}\\dlls\\$name";
}

View File

@@ -0,0 +1,6 @@
import 'package:get_storage/get_storage.dart';
final GetStorage _storage = GetStorage("reboot_update");
int? get updateTime => _storage.read("last_update_v2");
set updateTime(int? updateTime) => _storage.write("last_update_v2", updateTime);

View File

@@ -0,0 +1,78 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import '../../util/checks.dart';
import '../widget/shared/file_selector.dart';
import '../widget/shared/smart_check_box.dart';
import 'dialog.dart';
import 'dialog_button.dart';
class AddLocalVersion extends StatelessWidget {
final GameController _gameController = Get.find<GameController>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _gamePathController = TextEditingController();
AddLocalVersion({Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: double.infinity,
child: InfoBar(
title: Text("Local builds are not guaranteed to work"),
severity: InfoBarSeverity.info
),
),
const SizedBox(
height: 16.0
),
TextFormBox(
controller: _nameController,
header: "Name",
placeholder: "Type the version's name",
autofocus: true,
validator: (text) => checkVersion(text, _gameController.versions.value)
),
FileSelector(
label: "Path",
placeholder: "Type the game folder",
windowTitle: "Select game folder",
controller: _gamePathController,
validator: checkGameFolder,
folder: true
)
],
),
buttons: [
DialogButton(
type: ButtonType.secondary
),
DialogButton(
text: "Save",
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop();
_gameController.addVersion(FortniteVersion(
name: _nameController.text,
location: Directory(_gamePathController.text)
));
},
)
]
);
}
}

View File

@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:io';
import 'package:async/async.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import 'package:reboot_launcher/src/util/error.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/build.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import 'package:universal_disk_space/universal_disk_space.dart';
import '../../util/checks.dart';
import '../controller/build_controller.dart';
import '../widget/home/build_selector.dart';
import '../widget/home/version_name_input.dart';
import '../widget/shared/file_selector.dart';
import 'dialog.dart';
import 'dialog_button.dart';
class AddServerVersion extends StatefulWidget {
const AddServerVersion({Key? key}) : super(key: key);
@override
State<AddServerVersion> createState() => _AddServerVersionState();
}
class _AddServerVersionState extends State<AddServerVersion> {
final GameController _gameController = Get.find<GameController>();
final BuildController _buildController = Get.find<BuildController>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _pathController = TextEditingController();
late DiskSpace _diskSpace;
late Future _fetchFuture;
late Future _diskFuture;
DownloadStatus _status = DownloadStatus.form;
String _timeLeft = "00:00:00";
double _downloadProgress = 0;
CancelableOperation? _manifestDownloadProcess;
CancelableOperation? _driveDownloadOperation;
Object? _error;
StackTrace? _stackTrace;
@override
void initState() {
_fetchFuture = _buildController.builds != null
? Future.value(true)
: compute(fetchBuilds, null)
.then((value) => _buildController.builds = value);
_diskSpace = DiskSpace();
_diskFuture = _diskSpace.scan()
.then((_) => _updateFormDefaults());
_buildController.addOnBuildChangedListener(() => _updateFormDefaults());
super.initState();
}
@override
void dispose() {
_pathController.dispose();
_nameController.dispose();
_buildController.removeOnBuildChangedListener();
_onDisposed();
super.dispose();
}
void _onDisposed() {
if (_status != DownloadStatus.downloading) {
return;
}
if (_manifestDownloadProcess != null) {
_manifestDownloadProcess?.cancel();
_buildController.cancelledDownload(true);
return;
}
if (_driveDownloadOperation == null) {
return;
}
_driveDownloadOperation!.cancel();
_buildController.cancelledDownload(true);
}
@override
Widget build(BuildContext context) {
switch(_status){
case DownloadStatus.form:
return _createFormDialog();
case DownloadStatus.downloading:
return GenericDialog(
header: _createDownloadBody(),
buttons: _createCloseButton()
);
case DownloadStatus.extracting:
return GenericDialog(
header: _createExtractingBody(),
buttons: _createCloseButton()
);
case DownloadStatus.error:
return ErrorDialog(
exception: _error ?? Exception("unknown error"),
stackTrace: _stackTrace,
errorMessageBuilder: (exception) => "Cannot download version: $exception"
);
case DownloadStatus.done:
return const InfoDialog(
text: "The download was completed successfully!",
);
}
}
List<DialogButton> _createFormButtons() {
return [
DialogButton(type: ButtonType.secondary),
DialogButton(
text: "Download",
type: ButtonType.primary,
onTap: () => _startDownload(context),
)
];
}
void _startDownload(BuildContext context) async {
try {
setState(() => _status = DownloadStatus.downloading);
var future = downloadArchiveBuild(
_buildController.selectedBuild.link,
Directory(_pathController.text),
_onDownloadProgress,
_onUnrar
);
future.then((value) => _onDownloadComplete());
_manifestDownloadProcess = CancelableOperation.fromFuture(future);
} catch (exception, stackTrace) {
_onDownloadError(exception, stackTrace);
}
}
void _onUnrar() {
setState(() => _status = DownloadStatus.extracting);
}
Future<void> _onDownloadComplete() async {
if (!mounted) {
return;
}
setState(() {
_status = DownloadStatus.done;
_gameController.addVersion(FortniteVersion(
name: _nameController.text,
location: Directory(_pathController.text)
));
});
}
void _onDownloadError(Object? error, StackTrace? stackTrace) {
print("Error");
if (!mounted) {
return;
}
setState(() {
_status = DownloadStatus.error;
_error = error;
_stackTrace = stackTrace;
});
}
void _onDownloadProgress(double progress, String timeLeft) {
if (!mounted) {
return;
}
setState(() {
_status = DownloadStatus.downloading;
_timeLeft = timeLeft;
_downloadProgress = progress;
});
}
Widget _createDownloadBody() => Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
"Downloading...",
style: FluentTheme.maybeOf(context)?.typography.body,
textAlign: TextAlign.start,
),
),
const SizedBox(
height: 8.0,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${_downloadProgress.round()}%",
style: FluentTheme.maybeOf(context)?.typography.body,
),
Text(
"Time left: $_timeLeft",
style: FluentTheme.maybeOf(context)?.typography.body,
)
],
),
const SizedBox(
height: 8.0,
),
SizedBox(
width: double.infinity,
child: ProgressBar(value: _downloadProgress.toDouble())
),
const SizedBox(
height: 8.0,
)
],
);
Widget _createExtractingBody() => Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
"Extracting...",
style: FluentTheme.maybeOf(context)?.typography.body,
textAlign: TextAlign.start,
),
),
const SizedBox(
height: 8.0,
),
const SizedBox(
width: double.infinity,
child: ProgressBar()
),
const SizedBox(
height: 8.0,
)
],
);
Widget _createFormDialog() {
return FutureBuilder(
future: Future.wait([_fetchFuture, _diskFuture]),
builder: (context, snapshot) {
if (snapshot.hasError) {
WidgetsBinding.instance
.addPostFrameCallback((_) =>
_onDownloadError(snapshot.error, snapshot.stackTrace));
}
if (!snapshot.hasData) {
return ProgressDialog(
text: "Fetching builds and disks...",
onStop: () => Navigator.of(context).pop()
);
}
return FormDialog(
content: _createFormBody(),
buttons: _createFormButtons()
);
}
);
}
Widget _createFormBody() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const BuildSelector(),
const SizedBox(height: 20.0),
VersionNameInput(controller: _nameController),
FileSelector(
label: "Path",
placeholder: "Type the download destination",
windowTitle: "Select download destination",
controller: _pathController,
validator: checkDownloadDestination,
folder: true
),
],
);
}
List<DialogButton> _createCloseButton() {
return [
DialogButton(
text: "Stop",
type: ButtonType.only
)
];
}
Future<void> _updateFormDefaults() async {
if(_diskSpace.disks.isEmpty){
return;
}
await _fetchFuture;
var bestDisk = _diskSpace.disks
.reduce((first, second) => first.availableSpace > second.availableSpace ? first : second);
_pathController.text = "${bestDisk.devicePath}\\FortniteBuilds\\Fortnite "
"${_buildController.selectedBuild.version.toString()}";
_nameController.text = _buildController.selectedBuild.version.toString();
}
}
enum DownloadStatus { form, downloading, extracting, error, done }

View File

@@ -0,0 +1,260 @@
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:clipboard/clipboard.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/material.dart';
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
import 'dialog_button.dart';
abstract class AbstractDialog extends StatelessWidget {
const AbstractDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context);
}
class GenericDialog extends AbstractDialog {
final Widget header;
final List<DialogButton> buttons;
final EdgeInsets? padding;
const GenericDialog({super.key, required this.header, required this.buttons, this.padding});
@override
Widget build(BuildContext context) {
return Stack(
children: [
MoveWindow(
child: const SizedBox.expand(),
),
ContentDialog(
style: ContentDialogThemeData(
padding: padding ?? const EdgeInsets.only(left: 20, right: 20, top: 15.0, bottom: 5.0)
),
content: header,
actions: buttons
),
],
);
}
}
class FormDialog extends AbstractDialog {
final Widget content;
final List<DialogButton> buttons;
const FormDialog({super.key, required this.content, required this.buttons});
@override
Widget build(BuildContext context) {
return Form(
child: Builder(
builder: (context) {
var parsed = buttons.map((entry) => _createFormButton(entry, context)).toList();
return GenericDialog(
header: content,
buttons: parsed
);
}
)
);
}
DialogButton _createFormButton(DialogButton entry, BuildContext context) {
if (entry.type != ButtonType.primary) {
return entry;
}
return DialogButton(
text: entry.text,
type: entry.type,
onTap: () {
if(!Form.of(context)!.validate()) {
return;
}
entry.onTap?.call();
}
);
}
}
class InfoDialog extends AbstractDialog {
final String text;
final List<DialogButton>? buttons;
const InfoDialog({required this.text, this.buttons, super.key});
InfoDialog.ofOnly({required this.text, required DialogButton button, super.key})
: buttons = [button];
@override
Widget build(BuildContext context) {
return GenericDialog(
header: SizedBox(
width: double.infinity,
child: Text(text, textAlign: TextAlign.center)
),
buttons: buttons ?? [_createDefaultButton()],
padding: const EdgeInsets.only(left: 20, right: 20, top: 15.0, bottom: 15.0)
);
}
DialogButton _createDefaultButton() {
return DialogButton(
text: "Close",
type: ButtonType.only
);
}
}
class ProgressDialog extends AbstractDialog {
final String text;
final Function()? onStop;
const ProgressDialog({required this.text, this.onStop, super.key});
@override
Widget build(BuildContext context) {
return GenericDialog(
header: InfoLabel(
label: text,
child: Container(
padding: const EdgeInsets.only(bottom: 16.0),
width: double.infinity,
child: const ProgressBar()
),
),
buttons: [
DialogButton(
text: "Close",
type: ButtonType.only,
onTap: onStop
)
]
);
}
}
class FutureBuilderDialog extends AbstractDialog {
final Future future;
final String loadingMessage;
final Widget successfulBody;
final Widget unsuccessfulBody;
final Function(Object) errorMessageBuilder;
final Function()? onError;
final bool closeAutomatically;
const FutureBuilderDialog(
{super.key,
required this.future,
required this.loadingMessage,
required this.successfulBody,
required this.unsuccessfulBody,
required this.errorMessageBuilder,
this.onError,
this.closeAutomatically = false});
static Container ofMessage(String message) {
return Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
message,
textAlign: TextAlign.center
)
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: future,
builder: (context, snapshot) => GenericDialog(
header: _createBody(context, snapshot),
buttons: [_createButton(context, snapshot)]
)
);
}
Widget _createBody(BuildContext context, AsyncSnapshot snapshot){
if (snapshot.hasError) {
onError?.call();
return ofMessage(errorMessageBuilder(snapshot.error!));
}
if(snapshot.connectionState == ConnectionState.done && (snapshot.data == null || (snapshot.data is bool && !snapshot.data))){
return unsuccessfulBody;
}
if (!snapshot.hasData) {
return _createLoadingBody();
}
if(closeAutomatically){
WidgetsBinding.instance
.addPostFrameCallback((_) => Navigator.of(context).pop(true));
return _createLoadingBody();
}
return successfulBody;
}
InfoLabel _createLoadingBody() {
return InfoLabel(
label: loadingMessage,
child: Container(
padding: const EdgeInsets.only(bottom: 16.0),
width: double.infinity,
child: const ProgressBar()),
);
}
DialogButton _createButton(BuildContext context, AsyncSnapshot snapshot){
return DialogButton(
text: snapshot.hasData
|| snapshot.hasError
|| (snapshot.connectionState == ConnectionState.done && snapshot.data == null) ? "Close" : "Stop",
type: ButtonType.only,
onTap: () => Navigator.of(context).pop(!snapshot.hasError && snapshot.hasData)
);
}
}
class ErrorDialog extends AbstractDialog {
final Object exception;
final StackTrace? stackTrace;
final Function(Object) errorMessageBuilder;
const ErrorDialog({super.key, required this.exception, required this.errorMessageBuilder, this.stackTrace});
static DialogButton createCopyErrorButton({required Object error, required StackTrace? stackTrace, required Function() onClick, ButtonType type = ButtonType.primary}) => DialogButton(
text: "Copy error",
type: type,
onTap: () async {
FlutterClipboard.controlC("An error occurred: $error\nStacktrace:\n $stackTrace");
showMessage("Copied error to clipboard");
onClick();
},
);
@override
Widget build(BuildContext context) {
return InfoDialog(
text: errorMessageBuilder(exception),
buttons: [
DialogButton(
type: stackTrace == null ? ButtonType.only : ButtonType.secondary
),
if(stackTrace != null)
createCopyErrorButton(
error: exception,
stackTrace: stackTrace,
onClick: () => Navigator.pop(context)
)
],
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:fluent_ui/fluent_ui.dart';
class DialogButton extends StatefulWidget {
final String? text;
final Function()? onTap;
final ButtonType type;
const DialogButton(
{Key? key,
this.text,
this.onTap,
required this.type})
: assert(type != ButtonType.primary || onTap != null,
"OnTap handler cannot be null for primary buttons"),
assert(type != ButtonType.primary || text != null,
"Text cannot be null for primary buttons"),
super(key: key);
@override
State<DialogButton> createState() => _DialogButtonState();
}
class _DialogButtonState extends State<DialogButton> {
@override
Widget build(BuildContext context) {
return widget.type == ButtonType.only ? _createOnlyButton() : _createButton();
}
SizedBox _createOnlyButton() {
return SizedBox(
width: double.infinity,
child: _createButton()
);
}
Widget _createButton() {
return widget.type == ButtonType.primary ? _createPrimaryActionButton()
: _createSecondaryActionButton();
}
Widget _createPrimaryActionButton() {
return FilledButton(
onPressed: widget.onTap!,
child: Text(widget.text!),
);
}
Widget _createSecondaryActionButton() {
return Button(
onPressed: widget.onTap ?? _onDefaultSecondaryActionTap,
child: Text(widget.text ?? "Close"),
);
}
void _onDefaultSecondaryActionTap() {
Navigator.of(context).pop(null);
}
}
enum ButtonType {
primary,
secondary,
only
}

View File

@@ -0,0 +1,98 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import '../../../main.dart';
import 'dialog.dart';
const String _unsupportedServerError = "The build you are currently using is not supported by Reboot. "
"This means that you cannot currently host this version of the game. "
"For a list of supported versions, check #info in the Discord server. "
"If you are unsure which version works best, use build 7.40. "
"If you are a passionate programmer you can add support by opening a PR on Github. ";
const String _corruptedBuildError = "The build you are currently using is corrupted. "
"This means that some critical files are missing for the game to launch. "
"Download the build again from the launcher or, if it's not available there, from another source. "
"Occasionally some files might get corrupted if there isn't enough space on your drive.";
Future<void> showBrokenError() async {
showDialog(
context: appKey.currentContext!,
builder: (context) => const InfoDialog(
text: "The backend server is not working correctly"
)
);
}
Future<void> showMissingDllError(String name) async {
showDialog(
context: appKey.currentContext!,
builder: (context) => InfoDialog(
text: "$name dll is not a valid dll, fix it in the settings tab"
)
);
}
Future<void> showTokenErrorFixable() async {
showDialog(
context: appKey.currentContext!,
builder: (context) => const InfoDialog(
text: "A token error occurred. "
"The backend server has been automatically restarted to fix the issue. "
"The game has been restarted automatically. "
)
);
}
Future<void> showTokenErrorCouldNotFix() async {
showDialog(
context: appKey.currentContext!,
builder: (context) => const InfoDialog(
text: "A token error occurred. "
"The game couldn't be recovered, open an issue on Discord."
)
);
}
Future<void> showTokenErrorUnfixable() async {
showDialog(
context: appKey.currentContext!,
builder: (context) => const InfoDialog(
text: "A token error occurred. "
"This issue cannot be resolved automatically as the server isn't embedded."
"Please restart the server manually, then relaunch your game to check if the issue has been fixed. "
"Otherwise, open an issue on Discord."
)
);
}
Future<void> showCorruptedBuildError(bool server, [Object? error, StackTrace? stackTrace]) async {
if(error == null) {
showDialog(
context: appKey.currentContext!,
builder: (context) => InfoDialog(
text: server ? _unsupportedServerError : _corruptedBuildError
)
);
return;
}
showDialog(
context: appKey.currentContext!,
builder: (context) => ErrorDialog(
exception: error,
stackTrace: stackTrace,
errorMessageBuilder: (exception) => _corruptedBuildError
)
);
}
Future<void> showMissingBuildError(FortniteVersion version) async {
showDialog(
context: appKey.currentContext!,
builder: (context) => InfoDialog(
text: "${version.location.path} no longer contains a Fortnite executable. "
"This probably means that you deleted it or move it somewhere else."
)
);
}

View File

@@ -0,0 +1,318 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/model/server_type.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
import 'package:sync/semaphore.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../main.dart';
import '../../util/server.dart';
import '../controller/server_controller.dart';
import 'dialog.dart';
import 'dialog_button.dart';
extension ServerControllerDialog on ServerController {
static Semaphore semaphore = Semaphore();
Future<bool> restart(bool closeLocalPromptAutomatically) async {
await resetWinNat();
return (!started() || await stop()) && await toggle(closeLocalPromptAutomatically);
}
Future<bool> toggle(bool closeLocalPromptAutomatically) async {
try{
semaphore.acquire();
if (type() == ServerType.local) {
return _pingSelfInteractive(closeLocalPromptAutomatically);
}
var result = await _toggle();
if(!result){
started.value = false;
return false;
}
var ping = await _pingSelfInteractive(true);
if(!ping){
started.value = false;
return false;
}
return true;
}finally{
semaphore.release();
}
}
Future<bool> _toggle([ServerResultType? lastResultType]) async {
if (started.value) {
var result = await stop();
if (!result) {
started.value = true;
_showCannotStopError();
return true;
}
return false;
}
started.value = true;
var conditions = await checkServerPreconditions(host.text, port.text, type.value);
var result = conditions.type == ServerResultType.canStart ? await _startServer() : conditions;
if(result.type == ServerResultType.alreadyStarted) {
started.value = false;
return true;
}
var handled = await _handleResultType(result, lastResultType);
if (!handled) {
return false;
}
return handled;
}
Future<ServerResult> _startServer() async {
try{
switch(type()){
case ServerType.embedded:
startServer(detached());
break;
case ServerType.remote:
var uriResult = await _pingRemoteInteractive();
if(uriResult == null){
return ServerResult(
type: ServerResultType.cannotPingServer
);
}
remoteServer = await startRemoteServer(uriResult);
break;
case ServerType.local:
break;
}
}catch(error, stackTrace){
return ServerResult(
error: error,
stackTrace: stackTrace,
type: ServerResultType.unknownError
);
}
return ServerResult(
type: ServerResultType.canStart
);
}
Future<bool> _handleResultType(ServerResult result, ServerResultType? lastResultType) async {
var newResultType = result.type;
switch (newResultType) {
case ServerResultType.missingHostError:
_showMissingHostError();
return false;
case ServerResultType.missingPortError:
_showMissingPortError();
return false;
case ServerResultType.illegalPortError:
_showIllegalPortError();
return false;
case ServerResultType.cannotPingServer:
return false;
case ServerResultType.backendPortTakenError:
if (lastResultType == ServerResultType.backendPortTakenError) {
_showPortTakenError(3551);
return false;
}
var result = await _showPortTakenDialog(3551);
if (!result) {
return false;
}
await freeLawinPort();
await stop();
return _toggle(newResultType);
case ServerResultType.matchmakerPortTakenError:
if (lastResultType == ServerResultType.matchmakerPortTakenError) {
_showPortTakenError(8080);
return false;
}
var result = await _showPortTakenDialog(8080);
if (!result) {
return false;
}
await freeMatchmakerPort();
await stop();
return _toggle(newResultType);
case ServerResultType.unknownError:
if(lastResultType == ServerResultType.unknownError) {
_showUnknownError(result);
return false;
}
await resetWinNat();
await stop();
return _toggle(newResultType);
case ServerResultType.alreadyStarted:
case ServerResultType.canStart:
return true;
case ServerResultType.stopped:
return false;
}
}
Future<bool> _pingSelfInteractive(bool closeAutomatically) async {
try {
Future<bool> ping() async {
for(var i = 0; i < 3; i++){
var result = await pingSelf(port.text);
if(result != null){
return true;
}else {
await Future.delayed(const Duration(seconds: 1));
}
}
return false;
}
var future = _waitFutureOrTime(ping());
var result = await showDialog<bool>(
context: appKey.currentContext!,
builder: (context) =>
FutureBuilderDialog(
future: future,
loadingMessage: "Pinging ${type().id} server...",
successfulBody: FutureBuilderDialog.ofMessage(
"The ${type().id} server works correctly"),
unsuccessfulBody: FutureBuilderDialog.ofMessage(
"The ${type().id} server doesn't work. Check the backend tab for misconfigurations and try again."),
errorMessageBuilder: (
exception) => "An error occurred while pining the ${type().id} server: $exception",
closeAutomatically: closeAutomatically
)
) ?? false;
return result && await future;
} catch (_) {
return false;
}
}
Future<Uri?> _pingRemoteInteractive() async {
try {
var mainFuture = ping(host.text, port.text).then((value) => value != null);
var future = _waitFutureOrTime(mainFuture);
var result = await showDialog<bool>(
context: appKey.currentContext!,
builder: (context) =>
FutureBuilderDialog(
future: future,
closeAutomatically: false,
loadingMessage: "Pinging remote server...",
successfulBody: FutureBuilderDialog.ofMessage(
"The server at ${host.text}:${port
.text} works correctly"),
unsuccessfulBody: FutureBuilderDialog.ofMessage(
"The server at ${host.text}:${port
.text} doesn't work. Check the hostname and/or the port and try again."),
errorMessageBuilder: (exception) => "An error occurred while pining the server: $exception"
)
) ?? false;
return result ? await future : null;
} catch (_) {
return null;
}
}
Future<void> _showPortTakenError(int port) async {
showDialog(
context: appKey.currentContext!,
builder: (context) => InfoDialog(
text: "Port $port is already in use and the associating process cannot be killed. Kill it manually and try again.",
)
);
}
Future<bool> _showPortTakenDialog(int port) async {
return await showDialog<bool>(
context: appKey.currentContext!,
builder: (context) =>
InfoDialog(
text: "Port $port is already in use, do you want to kill the associated process?",
buttons: [
DialogButton(
type: ButtonType.secondary,
onTap: () => Navigator.of(context).pop(false),
),
DialogButton(
text: "Kill",
type: ButtonType.primary,
onTap: () => Navigator.of(context).pop(true),
),
],
)
) ?? false;
}
void _showCannotStopError() {
if(!started.value){
return;
}
showDialog(
context: appKey.currentContext!,
builder: (context) =>
const InfoDialog(
text: "Cannot stop backend server"
)
);
}
void showUnexpectedServerError() => showDialog(
context: appKey.currentContext!,
builder: (context) => InfoDialog(
text: "The backend server died unexpectedly",
buttons: [
DialogButton(
text: "Close",
type: ButtonType.secondary,
onTap: () => Navigator.of(context).pop(),
),
DialogButton(
text: "Open log",
type: ButtonType.primary,
onTap: () {
if(serverLogFile.existsSync()){
showMessage("No log is available");
}else {
launchUrl(serverLogFile.uri);
}
Navigator.of(context).pop();
}
),
],
)
);
void _showIllegalPortError() => showMessage("Illegal port for backend server, use only numbers");
void _showMissingPortError() => showMessage("Missing port for backend server");
void _showMissingHostError() => showMessage("Missing the host name for backend server");
Future<Object?> _showUnknownError(ServerResult result) => showDialog(
context: appKey.currentContext!,
builder: (context) =>
ErrorDialog(
exception: result.error ?? Exception("Unknown error"),
stackTrace: result.stackTrace,
errorMessageBuilder: (exception) => "Cannot start the backend: an unknown error occurred"
)
);
Future<dynamic> _waitFutureOrTime(Future<bool> resultFuture) => Future.wait<bool>([resultFuture, Future.delayed(const Duration(seconds: 1)).then((value) => true)]).then((value) => value.reduce((f, s) => f && s));
}

View File

@@ -0,0 +1,13 @@
import 'package:fluent_ui/fluent_ui.dart';
import '../../../main.dart';
void showMessage(String text){
showSnackbar(
appKey.currentContext!,
Snackbar(
content: Text(text, textAlign: TextAlign.center),
extended: true
)
);
}

View File

@@ -0,0 +1,213 @@
import 'package:bitsdojo_window/bitsdojo_window.dart' hide WindowBorder;
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get/get_rx/src/rx_types/rx_types.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/ui/page/launcher_page.dart';
import 'package:reboot_launcher/src/ui/page/server_page.dart';
import 'package:reboot_launcher/src/ui/page/settings_page.dart';
import 'package:window_manager/window_manager.dart';
import '../controller/settings_controller.dart';
import '../widget/os/window_border.dart';
import '../widget/os/window_buttons.dart';
import 'hosting_page.dart';
import 'info_page.dart';
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WindowListener {
static const double _defaultPadding = 12.0;
final SettingsController _settingsController = Get.find<SettingsController>();
final GlobalKey _searchKey = GlobalKey();
final FocusNode _searchFocusNode = FocusNode();
final TextEditingController _searchController = TextEditingController();
final Rxn<List<NavigationPaneItem>> _searchItems = Rxn();
final RxBool _focused = RxBool(true);
final RxInt _index = RxInt(0);
final RxBool _nestedNavigation = RxBool(false);
final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey();
@override
void initState() {
windowManager.addListener(this);
_searchController.addListener(_onSearch);
super.initState();
}
void _onSearch() {
if (searchValue.isEmpty) {
_searchItems.value = null;
return;
}
_searchItems.value = _allItems.whereType<PaneItem>()
.where((item) => (item.title as Text).data!.toLowerCase().contains(searchValue.toLowerCase()))
.toList()
.cast<NavigationPaneItem>();
}
@override
void dispose() {
windowManager.removeListener(this);
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
@override
void onWindowFocus() {
_focused.value = true;
}
@override
void onWindowBlur() {
_focused.value = false;
}
@override
void onWindowResized() {
_settingsController.saveWindowSize();
super.onWindowResized();
}
@override
void onWindowMoved() {
_settingsController.saveWindowOffset(appWindow.position);
super.onWindowMoved();
}
@override
Widget build(BuildContext context) => Obx(() => Stack(
children: [
NavigationView(
paneBodyBuilder: (body) => Padding(
padding: const EdgeInsets.all(_defaultPadding),
child: body
),
appBar: NavigationAppBar(
title: _draggableArea,
actions: WindowTitleBar(focused: _focused()),
leading: _backButton
),
pane: NavigationPane(
selected: _selectedIndex,
onChanged: _onIndexChanged,
displayMode: PaneDisplayMode.auto,
items: _items,
footerItems: _footerItems,
autoSuggestBox: _autoSuggestBox,
autoSuggestBoxReplacement: const Icon(FluentIcons.search),
),
onOpenSearch: () => _searchFocusNode.requestFocus(),
transitionBuilder: (child, animation) => child
),
if(_focused() && isWin11)
const WindowBorder()
]
));
Widget get _backButton => Obx(() {
// ignore: unused_local_variable
var ignored = _nestedNavigation.value;
return PaneItem(
icon: const Icon(FluentIcons.back, size: 14.0),
body: const SizedBox.shrink(),
).build(
context,
false,
_onBack(),
displayMode: PaneDisplayMode.compact
);
});
void Function()? _onBack() {
var navigator = _settingsNavigatorKey.currentState;
if(navigator == null || !navigator.mounted || !navigator.canPop()){
return null;
}
return () async {
Navigator.pop(navigator.context);
_nestedNavigation.value = false;
};
}
void _onIndexChanged(int index) => _index.value = index;
TextBox get _autoSuggestBox => TextBox(
key: _searchKey,
controller: _searchController,
placeholder: 'Search',
focusNode: _searchFocusNode
);
GestureDetector get _draggableArea => GestureDetector(
onDoubleTap: () => appWindow.maximizeOrRestore(),
onHorizontalDragStart: (event) => appWindow.startDragging(),
onVerticalDragStart: (event) => appWindow.startDragging()
);
int? get _selectedIndex {
var searchItems = _searchItems();
if (searchItems == null) {
return _index();
}
if(_index() >= _allItems.length){
return null;
}
var indexOnScreen = searchItems.indexOf(_allItems[_index()]);
if (indexOnScreen.isNegative) {
return null;
}
return indexOnScreen;
}
List<NavigationPaneItem> get _allItems => [..._items, ..._footerItems];
List<NavigationPaneItem> get _footerItems => searchValue.isNotEmpty ? [] : [
PaneItem(
title: const Text("Settings"),
icon: const Icon(FluentIcons.settings),
body: SettingsPage()
)
];
List<NavigationPaneItem> get _items => _searchItems() ?? [
PaneItem(
title: const Text("Play"),
icon: const Icon(FluentIcons.game),
body: const LauncherPage()
),
PaneItem(
title: const Text("Host"),
icon: const Icon(FluentIcons.server_processes),
body: const HostingPage()
),
PaneItem(
title: const Text("Backend"),
icon: const Icon(FluentIcons.user_window),
body: ServerPage()
),
PaneItem(
title: const Text("Tutorial"),
icon: const Icon(FluentIcons.info),
body: InfoPage(_settingsNavigatorKey, _nestedNavigation)
),
];
String get searchValue => _searchController.text;
}

View File

@@ -0,0 +1,105 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/ui/widget/home/launch_button.dart';
import 'package:reboot_launcher/src/ui/widget/home/version_selector.dart';
import 'package:reboot_launcher/src/ui/widget/shared/setting_tile.dart';
class HostingPage extends StatefulWidget {
const HostingPage(
{Key? key})
: super(key: key);
@override
State<HostingPage> createState() => _HostingPageState();
}
class _HostingPageState extends State<HostingPage> {
final HostingController _hostingController = Get.find<HostingController>();
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
const SizedBox(
width: double.infinity,
child: InfoBar(
title: Text("A window will pop up after the game server is started to modify its in-game settings"),
severity: InfoBarSeverity.info
),
),
const SizedBox(
height: 16.0
),
SettingTile(
title: "Game Server",
subtitle: "Provide basic information about your server",
expandedContentSpacing: 0,
expandedContent: [
SettingTile(
title: "Name",
subtitle: "The name of your game server",
isChild: true,
content: TextFormBox(
placeholder: "Name",
controller: _hostingController.name
)
),
SettingTile(
title: "Category",
subtitle: "The category of your game server",
isChild: true,
content: TextFormBox(
placeholder: "Category",
controller: _hostingController.category
)
),
SettingTile(
title: "Discoverable",
subtitle: "Make your server available to other players on the server browser",
isChild: true,
contentWidth: null,
content: Obx(() => ToggleSwitch(
checked: _hostingController.discoverable(),
onChanged: (value) => _hostingController.discoverable.value = value
))
),
],
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Version",
subtitle: "Select the version of Fortnite you want to host",
content: const VersionSelector(),
expandedContent: [
SettingTile(
title: "Add a version from this PC's local storage",
subtitle: "Versions coming from your local disk are not guaranteed to work",
content: Button(
onPressed: () => VersionSelector.openAddDialog(context),
child: const Text("Add build"),
),
isChild: true
),
SettingTile(
title: "Download any version from the cloud",
subtitle: "A curated list of supported versions by Project Reboot",
content: Button(
onPressed: () => VersionSelector.openDownloadDialog(context),
child: const Text("Download"),
),
isChild: true
)
]
),
const Expanded(child: SizedBox()),
const LaunchButton(
host: true
)
],
);
}

View File

@@ -0,0 +1,173 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../controller/settings_controller.dart';
import '../widget/shared/fluent_card.dart';
class InfoPage extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey;
final RxBool nestedNavigation;
const InfoPage(this.navigatorKey, this.nestedNavigation, {Key? key}) : super(key: key);
@override
State<InfoPage> createState() => _InfoPageState();
}
class _InfoPageState extends State<InfoPage> {
final List<String> _elseTitles = [
"Open the home page",
"Type the ip address of the host, including the port if it's not 7777\n The complete address should follow the schema ip:port",
"Type your username if you haven't already",
"Select the exact version that the host is using from the dropdown menu\n If necessary, install it using the download button",
"As you want to play, select client from the dropdown menu",
"Click launch to open the game\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
];
final List<String> _ownTitles = [
"Open the home page",
"Type 127.0.0.1 as the matchmaking host\n If you didn't know, 127.0.0.1 is the ip for your local machine",
"Type your username if you haven't already",
"Select the version you want to host\n If necessary, install it using the download button\n Check the supported versions in #info in the Discord server\n Fortnite 7.40 is the best one to use usually",
"As you want to host, select headless server from the dropdown menu\n If the headless server doesn't work for your version, use the normal server instead\n The difference between the two is that the first doesn't render a fortnite instance\n Both will not allow you to play, only to host\n You will see an infinite loading screen when using the normal server\n If you want to also play continue reading",
"Click launch to start the server and wait until the Reboot GUI shows up\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
"To allow your friends to join your server, follow the instructions on playit.gg\n If you are an advanced user, open port 7777 on your router\n Finally, share your playit ip or public IPv4 address with your friends\n If you just want to play by yourself, skip this step",
"When you want to start the game, click on the 'Start Bus Countdown' button\n Before clicking that button, make all of your friends join\n This is because joining mid-game isn't allowed",
"If you also want to play, start a client by selecting Client from the dropdown menu\n Don't close or open again the launcher, use the same window\n Remember to keep both the headless server(or server) and client open\n If you want to close the client or server, simply switch between them using the menu\n The launcher will remember what instances you have opened",
"Click launch to open the game\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
];
final SettingsController _settingsController = Get.find<SettingsController>();
late final ScrollController _controller;
@override
void initState() {
_controller = ScrollController(initialScrollOffset: _settingsController.scrollingDistance);
_controller.addListener(() {
_settingsController.scrollingDistance = _controller.offset;
});
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Navigator(
key: widget.navigatorKey,
initialRoute: "home",
onGenerateRoute: (settings) {
var screen = _createScreen(settings.name);
return FluentPageRoute(
builder: (context) => screen,
settings: settings
);
},
);
Widget _createScreen(String? name) {
WidgetsBinding.instance
.addPostFrameCallback((_) => widget.nestedNavigation.value = name != "home");
switch(name){
case "home":
return _homeScreen;
case "else":
return _createInstructions(false);
case "own":
return _createInstructions(true);
default:
throw Exception("Unknown page: $name");
}
}
Widget get _homeScreen => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_createCardWidget(
text: "Play on someone else's server",
description: "If one of your friends is hosting a game server, click here",
onClick: () => widget.navigatorKey.currentState?.pushNamed("else")
),
const SizedBox(
width: 8.0,
),
_createCardWidget(
text: "Host your own server",
description: "If you want to create your own server to invite your friends or to play around by yourself, click here",
onClick: () => widget.navigatorKey.currentState?.pushNamed("own")
)
]
);
SizedBox _createInstructions(bool own) {
var titles = own ? _ownTitles : _elseTitles;
var codeName = own ? "own" : "else";
return SizedBox.expand(
child: ListView.separated(
controller: _controller,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(
right: 20.0
),
child: FluentCard(
child: ListTile(
title: SelectableText("${index + 1}. ${titles[index]}"),
subtitle: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Image.asset("assets/images/tutorial_${codeName}_${index + 1}.png"),
)
)
),
),
separatorBuilder: (context, index) => const SizedBox(height: 8.0),
itemCount: titles.length,
)
);
}
Widget _createCardWidget({required String text, required String description, required Function() onClick}) => Expanded(
child: SizedBox(
height: double.infinity,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onClick,
child: FluentCard(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.bold
),
),
const SizedBox(
height: 8.0,
),
Text(
description,
textAlign: TextAlign.center
),
],
)
)
)
)
)
)
);
}

View File

@@ -0,0 +1,216 @@
import 'dart:async';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/material.dart' show Icons;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/build_controller.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
import 'package:reboot_launcher/src/ui/widget/home/launch_button.dart';
import 'package:reboot_launcher/src/ui/widget/home/version_selector.dart';
import 'package:reboot_launcher/src/ui/widget/shared/setting_tile.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../model/update_status.dart';
import '../../util/checks.dart';
import '../../util/reboot.dart';
import '../controller/update_controller.dart';
class LauncherPage extends StatefulWidget {
const LauncherPage(
{Key? key})
: super(key: key);
@override
State<LauncherPage> createState() => _LauncherPageState();
}
class _LauncherPageState extends State<LauncherPage> {
final GameController _gameController = Get.find<GameController>();
final SettingsController _settingsController = Get.find<SettingsController>();
final BuildController _buildController = Get.find<BuildController>();
late final RxBool _showPasswordTrailing = RxBool(_gameController.password.text.isNotEmpty);
@override
void initState() {
if(_gameController.updateStatus() == UpdateStatus.waiting) {
_startUpdater();
_setupBuildWarning();
}
super.initState();
}
void _setupBuildWarning() {
void onCancelWarning() => WidgetsBinding.instance.addPostFrameCallback((_) {
if(!mounted) {
return;
}
showSnackbar(context, const Snackbar(content: Text("Download cancelled")));
_buildController.cancelledDownload(false);
});
_buildController.cancelledDownload.listen((value) => value ? onCancelWarning() : {});
}
Future<void> _startUpdater() async {
if(!_settingsController.autoUpdate()){
_gameController.updateStatus.value = UpdateStatus.success;
return;
}
_gameController.updateStatus.value = UpdateStatus.started;
try {
updateTime = await downloadRebootDll(_settingsController.updateUrl.text, updateTime);
_gameController.updateStatus.value = UpdateStatus.success;
}catch(_) {
_gameController.updateStatus.value = UpdateStatus.error;
rethrow;
}
}
@override
Widget build(BuildContext context) => Obx(() => !_settingsController.autoUpdate() || _gameController.updateStatus().isDone() ? _homePage : _updateScreen);
Widget get _homePage => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _gameController.updateStatus() == UpdateStatus.error ? _updateError : const SizedBox(),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(height: _gameController.updateStatus() == UpdateStatus.error ? 16.0 : 0.0),
),
SettingTile(
title: "Credentials",
subtitle: "Your in-game login credentials",
expandedContentSpacing: 0,
expandedContent: [
SettingTile(
title: "Username",
subtitle: "The username that other players will see when you are in game",
isChild: true,
content: TextFormBox(
placeholder: "Username",
controller: _gameController.username,
autovalidateMode: AutovalidateMode.always
),
),
SettingTile(
title: "Password",
subtitle: "The password of your account, only used if the backend requires it",
isChild: true,
content: Obx(() => TextFormBox(
placeholder: "Password",
controller: _gameController.password,
autovalidateMode: AutovalidateMode.always,
obscureText: !_gameController.showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty,
suffix: Button(
onPressed: () => _gameController.showPassword.value = !_gameController.showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
_gameController.showPassword.value ? Icons.visibility_off : Icons.visibility,
color: _showPasswordTrailing.value ? null : Colors.transparent
),
)
))
)
],
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Matchmaking host",
subtitle: "Enter the IP address of the game server hosting the match",
content: TextFormBox(
placeholder: "IP:PORT",
controller: _settingsController.matchmakingIp,
validator: checkMatchmaking,
autovalidateMode: AutovalidateMode.always
),
expandedContent: [
SettingTile(
title: "Browse available servers",
subtitle: "Discover new game servers that fit your play-style",
content: Button(
onPressed: () => launchUrl(Uri.parse("https://google.com/search?q=One+Day+This+Will+Be+Ready")),
child: const Text("Browse")
),
isChild: true
)
]
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Version",
subtitle: "Select the version of Fortnite you want to play",
content: const VersionSelector(),
expandedContent: [
SettingTile(
title: "Add a version from this PC's local storage",
subtitle: "Versions coming from your local disk are not guaranteed to work",
content: Button(
onPressed: () => VersionSelector.openAddDialog(context),
child: const Text("Add build"),
),
isChild: true
),
SettingTile(
title: "Download any version from the cloud",
subtitle: "A curated list of supported versions by Project Reboot",
content: Button(
onPressed: () => VersionSelector.openDownloadDialog(context),
child: const Text("Download"),
),
isChild: true
)
]
),
const Expanded(child: SizedBox()),
const LaunchButton(
host: false
)
],
);
Widget get _updateScreen => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
ProgressRing(),
SizedBox(height: 16.0),
Text("Updating Reboot DLL...")
],
),
],
);
Widget get _updateError => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _startUpdater(),
child: const SizedBox(
width: double.infinity,
child: InfoBar(
title: Text("The reboot dll couldn't be downloaded: click here to try again"),
severity: InfoBarSeverity.info
)
),
),
);
}

View File

@@ -0,0 +1,92 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/model/server_type.dart';
import 'package:reboot_launcher/src/util/server.dart';
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
import 'package:reboot_launcher/src/ui/widget/server/server_type_selector.dart';
import 'package:reboot_launcher/src/ui/widget/server/server_button.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widget/shared/setting_tile.dart';
class ServerPage extends StatelessWidget {
final ServerController _serverController = Get.find<ServerController>();
ServerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: double.infinity,
child: InfoBar(
title: Text("The backend server handles authentication and parties, not game hosting"),
severity: InfoBarSeverity.info
),
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Host",
subtitle: "Enter the host of the backend server",
content: TextFormBox(
placeholder: "Host",
controller: _serverController.host,
enabled: _isRemote
)
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Port",
subtitle: "Enter the port of the backend server",
content: TextFormBox(
placeholder: "Port",
controller: _serverController.port,
enabled: _isRemote
)
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Type",
subtitle: "Select the type of backend to use",
content: ServerTypeSelector()
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Detached",
subtitle: "Choose whether the backend should be started as a separate process, useful for debugging",
contentWidth: null,
content: Obx(() => ToggleSwitch(
checked: _serverController.detached(),
onChanged: (value) => _serverController.detached.value = value
))
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Server files",
subtitle: "The location where the backend is stored",
content: Button(
onPressed: () => launchUrl(serverDirectory.uri),
child: const Text("Open")
)
),
const Expanded(child: SizedBox()),
const ServerButton()
]
));
}
bool get _isRemote => _serverController.type.value == ServerType.remote;
}

View File

@@ -0,0 +1,157 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/dialog_button.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../util/checks.dart';
import '../../util/os.dart';
import '../../util/selector.dart';
import '../dialog/dialog.dart';
import '../widget/shared/setting_tile.dart';
class SettingsPage extends StatelessWidget {
final GameController _gameController = Get.find<GameController>();
final SettingsController _settingsController = Get.find<SettingsController>();
SettingsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingTile(
title: "File settings",
subtitle: "This section contains all the settings related to files used by Fortnite",
expandedContent: [
_createFileSetting(
title: "Game server",
description: "This file is injected to create a game server to host matches",
controller: _settingsController.rebootDll
),
_createFileSetting(
title: "Unreal engine console",
description: "This file is injected to unlock the Unreal Engine Console in-game",
controller: _settingsController.consoleDll
),
_createFileSetting(
title: "Authentication patcher",
description: "This file is injected to redirect all HTTP requests to the local backend",
controller: _settingsController.authDll
),
],
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Automatic updates",
subtitle: "Choose whether the launcher and its files should be automatically updated",
contentWidth: null,
content: Obx(() => ToggleSwitch(
checked: _settingsController.autoUpdate(),
onChanged: (value) => _settingsController.autoUpdate.value = value
))
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Custom launch arguments",
subtitle: "Enter additional arguments to use when launching the game",
content: TextFormBox(
placeholder: "Arguments...",
controller: _gameController.customLaunchArgs,
)
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Create a bug report",
subtitle: "Help me fix bugs by reporting them",
content: Button(
onPressed: () => launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/issues/new/choose")),
child: const Text("Report a bug"),
)
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Reset settings",
subtitle: "Resets the launcher's settings to their default values",
content: Button(
onPressed: () => showDialog(
context: context,
builder: (context) => InfoDialog(
text: "Do you want to reset all settings to their default values? This action is irreversible",
buttons: [
DialogButton(
type: ButtonType.secondary,
text: "Close",
),
DialogButton(
type: ButtonType.primary,
text: "Reset",
onTap: () {
_settingsController.reset();
Navigator.of(context).pop();
},
)
],
)
),
child: const Text("Reset"),
)
),
const SizedBox(
height: 16.0,
),
SettingTile(
title: "Version status",
subtitle: "Current version: 7.0",
content: Button(
onPressed: () => launchUrl(installationDirectory.uri),
child: const Text("Show Files"),
)
),
]
);
Widget _createFileSetting({required String title, required String description, required TextEditingController controller}) => ListTile(
title: Text(title),
subtitle: Text(description),
trailing: SizedBox(
width: 256,
child: Row(
children: [
Expanded(
child: TextFormBox(
placeholder: "Path",
controller: controller,
validator: checkDll,
autovalidateMode: AutovalidateMode.always
),
),
const SizedBox(
width: 8.0,
),
Padding(
padding: const EdgeInsets.only(bottom: 21.0),
child: Button(
onPressed: () async {
var selected = await compute(openFilePicker, "dll");
controller.text = selected ?? controller.text;
},
child: const Icon(FluentIcons.open_folder_horizontal),
),
)
],
)
)
);
}

View File

@@ -0,0 +1,44 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/build_controller.dart';
import 'package:reboot_launcher/src/model/fortnite_build.dart';
class BuildSelector extends StatefulWidget {
const BuildSelector({Key? key}) : super(key: key);
@override
State<BuildSelector> createState() => _BuildSelectorState();
}
class _BuildSelectorState extends State<BuildSelector> {
final BuildController _buildController = Get.find<BuildController>();
@override
Widget build(BuildContext context) {
return InfoLabel(
label: "Build",
child: ComboBox<FortniteBuild>(
placeholder: const Text('Select a fortnite build'),
isExpanded: true,
items: _createItems(),
value: _buildController.selectedBuild,
onChanged: (value) =>
value == null ? {} : setState(() => _buildController.selectedBuild = value)
)
);
}
List<ComboBoxItem<FortniteBuild>> _createItems() {
return _buildController.builds!
.map((element) => _createItem(element))
.toList();
}
ComboBoxItem<FortniteBuild> _createItem(FortniteBuild element) {
return ComboBoxItem<FortniteBuild>(
value: element,
child: Text(element.version.toString())
);
}
}

View File

@@ -0,0 +1,407 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:process_run/shell.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import 'package:reboot_launcher/src/ui/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/dialog.dart';
import 'package:reboot_launcher/src/ui/dialog/game_dialogs.dart';
import 'package:reboot_launcher/src/ui/dialog/server_dialogs.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import 'package:reboot_launcher/src/model/server_type.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/injector.dart';
import 'package:reboot_launcher/src/util/patcher.dart';
import 'package:reboot_launcher/src/util/server.dart';
import 'package:path/path.dart' as path;
import 'package:reboot_launcher/src/../main.dart';
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
import 'package:reboot_launcher/src/model/game_instance.dart';
import '../../../util/process.dart';
class LaunchButton extends StatefulWidget {
final bool host;
const LaunchButton({Key? key, required this.host}) : super(key: key);
@override
State<LaunchButton> createState() => _LaunchButtonState();
}
class _LaunchButtonState extends State<LaunchButton> {
final String _shutdownLine = "FOnlineSubsystemGoogleCommon::Shutdown()";
final List<String> _corruptedBuildErrors = [
"when 0 bytes remain",
"Pak chunk signature verification failed!"
];
final List<String> _errorStrings = [
"port 3551 failed: Connection refused",
"Unable to login to Fortnite servers",
"HTTP 400 response from ",
"Network failure when attempting to check platform restrictions",
"UOnlineAccountCommon::ForceLogout"
];
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final ServerController _serverController = Get.find<ServerController>();
final SettingsController _settingsController = Get.find<SettingsController>();
final File _logFile = File("${assetsDirectory.path}\\logs\\game.log");
bool _fail = false;
Future? _executor;
@override
Widget build(BuildContext context) => Align(
alignment: AlignmentDirectional.bottomCenter,
child: SizedBox(
width: double.infinity,
child: Obx(() => SizedBox(
height: 48,
child: Button(
child: Align(
alignment: Alignment.center,
child: Text(
_hasStarted ? _stopMessage : _startMessage
),
),
onPressed: () => _executor = _start()
),
)),
),
);
bool get _hasStarted => widget.host ? _hostingController.started() : _gameController.started();
void _setStarted(bool hosting, bool started) => hosting ? _hostingController.started.value = started : _gameController.started.value = started;
String get _startMessage => widget.host ? "Start hosting" : "Launch fortnite";
String get _stopMessage => widget.host ? "Stop hosting" : "Close fortnite";
Future<void> _start() async {
if (_hasStarted) {
_onStop(widget.host);
return;
}
_setStarted(widget.host, true);
if (_gameController.username.text.isEmpty) {
if(_serverController.type() != ServerType.local){
showMessage("Missing username");
_onStop(widget.host);
return;
}
showMessage("No username: expecting self sign in");
}
if (_gameController.selectedVersion == null) {
showMessage("No version is selected");
_onStop(widget.host);
return;
}
for (var element in Injectable.values) {
if(await _getDllPath(element, widget.host) == null) {
return;
}
}
try {
_fail = false;
var version = _gameController.selectedVersion!;
var gamePath = version.executable?.path;
if(gamePath == null){
showMissingBuildError(version);
_onStop(widget.host);
return;
}
var result = _serverController.started() || await _serverController.toggle(true);
if(!result){
_onStop(widget.host);
return;
}
await compute(patchMatchmaking, version.executable!);
await compute(patchHeadless, version.executable!);
var automaticallyStartedServer = await _startMatchMakingServer();
await _startGameProcesses(version, widget.host, automaticallyStartedServer);
if(widget.host){
await _showServerLaunchingWarning();
}
} catch (exception, stacktrace) {
_closeDialogIfOpen(false);
showCorruptedBuildError(widget.host, exception, stacktrace);
_onStop(widget.host);
}
}
Future<void> _startGameProcesses(FortniteVersion version, bool host, bool hasChildServer) async {
_setStarted(host, true);
var launcherProcess = await _createLauncherProcess(version);
var eacProcess = await _createEacProcess(version);
var gameProcess = await _createGameProcess(version.executable!.path, host);
var instance = GameInstance(gameProcess, launcherProcess, eacProcess, hasChildServer);
if(host){
_hostingController.instance = instance;
}else{
_gameController.instance = instance;
}
_injectOrShowError(Injectable.sslBypass, host);
}
Future<bool> _startMatchMakingServer() async {
if(widget.host){
return false;
}
var matchmakingIp = _settingsController.matchmakingIp.text;
if(!matchmakingIp.contains("127.0.0.1") && !matchmakingIp.contains("localhost")) {
return false;
}
var version = _gameController.selectedVersion!;
await _startGameProcesses(version, true, false);
return true;
}
Future<Process> _createGameProcess(String gamePath, bool host) async {
var gameArgs = createRebootArgs(_safeUsername, host, _gameController.customLaunchArgs.text);
var gameProcess = await Process.start(gamePath, gameArgs);
gameProcess
..exitCode.then((_) => _onEnd())
..outLines.forEach((line) => _onGameOutput(line, host))
..errLines.forEach((line) => _onGameOutput(line, host));
return gameProcess;
}
String get _safeUsername {
if (_gameController.username.text.isEmpty) {
return kDefaultPlayerName;
}
var username = _gameController.username.text.replaceAll(RegExp("[^A-Za-z0-9]"), "").trim();
if(username.isEmpty){
return kDefaultPlayerName;
}
return username;
}
Future<Process?> _createLauncherProcess(FortniteVersion version) async {
var launcherFile = version.launcher;
if (launcherFile == null) {
return null;
}
var launcherProcess = await Process.start(launcherFile.path, []);
suspend(launcherProcess.pid);
return launcherProcess;
}
Future<Process?> _createEacProcess(FortniteVersion version) async {
var eacFile = version.eacExecutable;
if (eacFile == null) {
return null;
}
var eacProcess = await Process.start(eacFile.path, []);
suspend(eacProcess.pid);
return eacProcess;
}
void _onEnd() {
if(_fail){
return;
}
_closeDialogIfOpen(false);
_onStop(widget.host);
}
void _closeDialogIfOpen(bool success) {
var route = ModalRoute.of(appKey.currentContext!);
if(route == null || route.isCurrent){
return;
}
Navigator.of(appKey.currentContext!).pop(success);
}
Future<void> _showServerLaunchingWarning() async {
var result = await showDialog<bool>(
context: appKey.currentContext!,
builder: (context) => ProgressDialog(
text: "Launching headless server...",
onStop: () =>_onEnd()
)
) ?? false;
if(result){
return;
}
_onStop(widget.host);
}
void _onGameOutput(String line, bool host) {
_logFile.createSync(recursive: true);
_logFile.writeAsString("$line\n", mode: FileMode.append);
if (line.contains(_shutdownLine)) {
_onStop(host);
return;
}
if(_corruptedBuildErrors.any((element) => line.contains(element))){
if(_fail){
return;
}
_fail = true;
showCorruptedBuildError(host);
_onStop(host);
return;
}
if(_errorStrings.any((element) => line.contains(element))){
if(_fail){
return;
}
_fail = true;
_closeDialogIfOpen(false);
_showTokenError(host);
return;
}
if(line.contains("Region ")){
if(!host){
_injectOrShowError(Injectable.console, host);
}else {
_injectOrShowError(Injectable.reboot, host)
.then((value) => _closeDialogIfOpen(true));
}
_injectOrShowError(Injectable.memoryFix, host);
var instance = host ? _hostingController.instance : _gameController.instance;
instance?.tokenError = false;
}
}
Future<void> _showTokenError(bool host) async {
var instance = host ? _hostingController.instance : _gameController.instance;
if(_serverController.type() != ServerType.embedded) {
showTokenErrorUnfixable();
instance?.tokenError = true;
return;
}
var tokenError = instance?.tokenError;
instance?.tokenError = true;
await _serverController.restart(true);
if (tokenError == true) {
showTokenErrorCouldNotFix();
return;
}
showTokenErrorFixable();
_onStop(host);
_start();
}
void _onStop(bool host) async {
if(_executor != null){
await _executor;
}
var instance = host ? _hostingController.instance : _gameController.instance;
if(instance != null){
if(instance.hasChildServer){
_onStop(true);
}
instance.kill();
if(host){
_hostingController.instance = null;
}else {
_gameController.instance = null;
}
}
_setStarted(host, false);
}
Future<void> _injectOrShowError(Injectable injectable, bool hosting) async {
var instance = hosting ? _hostingController.instance : _gameController.instance;
if (instance == null) {
return;
}
try {
var gameProcess = instance.gameProcess;
var dllPath = await _getDllPath(injectable, hosting);
if(dllPath == null) {
return;
}
await injectDll(gameProcess.pid, dllPath.path);
} catch (exception) {
showMessage("Cannot inject $injectable.dll: $exception");
_onStop(hosting);
}
}
Future<File?> _getDllPath(Injectable injectable, bool hosting) async {
Future<File> getPath(Injectable injectable) async {
switch(injectable){
case Injectable.reboot:
return File(_settingsController.rebootDll.text);
case Injectable.console:
return File(_settingsController.consoleDll.text);
case Injectable.sslBypass:
return File(_settingsController.authDll.text);
case Injectable.memoryFix:
return File("${assetsDirectory.path}\\dlls\\memoryleak.dll");
}
}
var dllPath = await getPath(injectable);
if(dllPath.existsSync()) {
return dllPath;
}
_onDllFail(dllPath, hosting);
return null;
}
void _onDllFail(File dllPath, bool hosting) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if(_fail){
return;
}
_fail = true;
_closeDialogIfOpen(false);
showMissingDllError(path.basename(dllPath.path));
_onStop(hosting);
});
}
}
enum Injectable {
console,
sslBypass,
reboot,
memoryFix
}

View File

@@ -0,0 +1,33 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
class VersionNameInput extends StatelessWidget {
final GameController _gameController = Get.find<GameController>();
final TextEditingController controller;
VersionNameInput({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormBox(
header: "Name",
placeholder: "Type the version's name",
controller: controller,
autofocus: true,
validator: _validate,
);
}
String? _validate(String? text) {
if (text == null || text.isEmpty) {
return 'Empty version name';
}
if (_gameController.versions.value.any((element) => element.name == text)) {
return 'This version already exists';
}
return null;
}
}

View File

@@ -0,0 +1,271 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/dialog.dart';
import 'package:reboot_launcher/src/ui/dialog/dialog_button.dart';
import 'package:reboot_launcher/src/model/fortnite_version.dart';
import 'package:reboot_launcher/src/ui/dialog/add_local_version.dart';
import 'package:reboot_launcher/src/ui/widget/shared/smart_check_box.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:reboot_launcher/src/ui/dialog/add_server_version.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import '../shared/file_selector.dart';
class VersionSelector extends StatefulWidget {
const VersionSelector({Key? key}) : super(key: key);
static void openDownloadDialog(BuildContext context) async {
await showDialog<bool>(
context: context,
builder: (dialogContext) => const AddServerVersion()
);
}
static void openAddDialog(BuildContext context) async {
await showDialog<bool>(
context: context,
builder: (context) => AddLocalVersion());
}
@override
State<VersionSelector> createState() => _VersionSelectorState();
}
class _VersionSelectorState extends State<VersionSelector> {
final GameController _gameController = Get.find<GameController>();
final CheckboxController _deleteFilesController = CheckboxController();
final FlyoutController _flyoutController = FlyoutController();
@override
Widget build(BuildContext context) => Obx(() => _createOptionsMenu(
version: _gameController.selectedVersion,
close: false,
child: FlyoutTarget(
controller: _flyoutController,
child: DropDownButton(
leading: Text(_gameController.selectedVersion?.name ?? "Select a version"),
items: _createSelectorItems(context)
),
)
));
List<MenuFlyoutItem> _createSelectorItems(BuildContext context) => _gameController.hasNoVersions ? [_createDefaultVersionItem()]
: _gameController.versions.value
.map((version) => _createVersionItem(context, version))
.toList();
MenuFlyoutItem _createDefaultVersionItem() => MenuFlyoutItem(
text: const Text("Please create or download a version"),
onPressed: () {}
);
MenuFlyoutItem _createVersionItem(BuildContext context, FortniteVersion version) => MenuFlyoutItem(
text: _createOptionsMenu(
version: version,
close: true,
child: Text(version.name),
),
onPressed: () => _gameController.selectedVersion = version
);
Widget _createOptionsMenu({required FortniteVersion? version, required bool close, required Widget child}) => Listener(
onPointerDown: (event) async {
if (event.kind != PointerDeviceKind.mouse || event.buttons != kSecondaryMouseButton) {
return;
}
if(version == null) {
return;
}
var result = await _flyoutController.showFlyout<ContextualOption?>(
builder: (context) => MenuFlyout(
items: ContextualOption.values
.map((entry) => _createOption(context, entry))
.toList()
)
);
_handleResult(result, version, close);
},
child: child
);
void _handleResult(ContextualOption? result, FortniteVersion version, bool close) async {
switch (result) {
case ContextualOption.openExplorer:
if(!mounted){
return;
}
if(close) {
Navigator.of(context).pop();
}
launchUrl(version.location.uri)
.onError((error, stackTrace) => _onExplorerError());
break;
case ContextualOption.modify:
if(!mounted){
return;
}
if(close) {
Navigator.of(context).pop();
}
await _openRenameDialog(context, version);
break;
case ContextualOption.delete:
if(!mounted){
return;
}
var result = await _openDeleteDialog(context, version) ?? false;
if(!mounted || !result){
return;
}
if(close) {
Navigator.of(context).pop();
}
_gameController.removeVersion(version);
if (_gameController.selectedVersion?.name == version.name || _gameController.hasNoVersions) {
_gameController.selectedVersion = null;
}
if (_deleteFilesController.value && await version.location.exists()) {
version.location.delete(recursive: true);
}
break;
default:
break;
}
}
MenuFlyoutItem _createOption(BuildContext context, ContextualOption entry) {
return MenuFlyoutItem(
text: Text(entry.name),
onPressed: () => Navigator.of(context).pop(entry)
);
}
bool _onExplorerError() {
showSnackbar(
context,
const Snackbar(
content: Text("This version doesn't exist on the local machine", textAlign: TextAlign.center),
extended: true
)
);
return false;
}
Future<bool?> _openDeleteDialog(BuildContext context, FortniteVersion version) {
return showDialog<bool>(
context: context,
builder: (context) => ContentDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: double.infinity,
child: Text("Are you sure you want to delete this version?")),
const SizedBox(height: 12.0),
SmartCheckBox(
controller: _deleteFilesController,
content: const Text("Delete version files from disk")
)
],
),
actions: [
Button(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Keep'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
)
],
)
);
}
Future<String?> _openRenameDialog(BuildContext context, FortniteVersion version) {
var nameController = TextEditingController(text: version.name);
var pathController = TextEditingController(text: version.location.path);
return showDialog<String?>(
context: context,
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormBox(
controller: nameController,
header: "Name",
placeholder: "Type the new version name",
autofocus: true,
validator: (text) => checkChangeVersion(text)
),
const SizedBox(
height: 16.0
),
FileSelector(
placeholder: "Type the new game folder",
windowTitle: "Select game folder",
controller: pathController,
validator: checkGameFolder,
folder: true
),
const SizedBox(height: 8.0),
],
),
buttons: [
DialogButton(
type: ButtonType.secondary
),
DialogButton(
text: "Save",
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop();
_gameController.updateVersion(version, (version) {
version.name = nameController.text;
version.location = Directory(pathController.text);
});
},
)
]
)
);
}
}
enum ContextualOption {
openExplorer,
modify,
delete;
String get name {
return this == ContextualOption.openExplorer ? "Open in explorer"
: this == ContextualOption.modify ? "Modify"
: "Delete";
}
}

View File

@@ -0,0 +1,27 @@
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:system_theme/system_theme.dart';
class WindowBorder extends StatelessWidget {
const WindowBorder({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Padding(
padding: EdgeInsets.only(
top: 1 / appWindow.scaleFactor
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: SystemTheme.accentColor.accent,
width: appBarSize.toDouble()
)
)
),
));
}
}

View File

@@ -0,0 +1,51 @@
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:system_theme/system_theme.dart';
class WindowTitleBar extends StatelessWidget {
final bool focused;
const WindowTitleBar({Key? key, required this.focused}) : super(key: key);
@override
Widget build(BuildContext context) {
var lightMode = FluentTheme.of(context).brightness.isLight;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MinimizeWindowButton(
colors: WindowButtonColors(
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
iconMouseDown: lightMode ? Colors.black : Colors.white,
iconMouseOver: lightMode ? Colors.black : Colors.white,
normal: Colors.transparent,
mouseOver: _color,
mouseDown: _color.withOpacity(0.7)),
),
MaximizeWindowButton(
colors: WindowButtonColors(
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
iconMouseDown: lightMode ? Colors.black : Colors.white,
iconMouseOver: lightMode ? Colors.black : Colors.white,
normal: Colors.transparent,
mouseOver: _color,
mouseDown: _color.withOpacity(0.7)),
),
CloseWindowButton(
colors: WindowButtonColors(
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
iconMouseDown: lightMode ? Colors.black : Colors.white,
iconMouseOver: lightMode ? Colors.black : Colors.white,
normal: Colors.transparent,
mouseOver: Colors.red,
mouseDown: Colors.red.withOpacity(0.7),
),
),
],
);
}
Color get _color =>
SystemTheme.accentColor.accent;
}

View File

@@ -0,0 +1,46 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
import 'package:reboot_launcher/src/ui/dialog/server_dialogs.dart';
import 'package:reboot_launcher/src/model/server_type.dart';
class ServerButton extends StatefulWidget {
const ServerButton({Key? key}) : super(key: key);
@override
State<ServerButton> createState() => _ServerButtonState();
}
class _ServerButtonState extends State<ServerButton> {
final ServerController _serverController = Get.find<ServerController>();
@override
Widget build(BuildContext context) => Align(
alignment: AlignmentDirectional.bottomCenter,
child: SizedBox(
width: double.infinity,
child: Obx(() => SizedBox(
height: 48,
child: Button(
child: Align(
alignment: Alignment.center,
child: Text(_buttonText),
),
onPressed: () => _serverController.toggle(false)
),
)),
),
);
String get _buttonText {
if(_serverController.type.value == ServerType.local){
return "Check backend";
}
if(_serverController.started.value){
return "Stop backend";
}
return "Start backend";
}
}

View File

@@ -0,0 +1,34 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
import 'package:reboot_launcher/src/model/server_type.dart';
class ServerTypeSelector extends StatelessWidget {
final ServerController _serverController = Get.find<ServerController>();
ServerTypeSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DropDownButton(
leading: Text(_serverController.type.value.name),
items: ServerType.values
.map((type) => _createItem(type))
.toList()
);
}
MenuFlyoutItem _createItem(ServerType type) {
return MenuFlyoutItem(
text: Tooltip(
message: type.message,
child: Text(type.name)
),
onPressed: () async {
await _serverController.stop();
_serverController.type(type);
}
);
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
import 'package:reboot_launcher/src/util/selector.dart';
class FileSelector extends StatefulWidget {
final String placeholder;
final String windowTitle;
final bool allowNavigator;
final TextEditingController controller;
final String? Function(String?) validator;
final AutovalidateMode? validatorMode;
final String? extension;
final String? label;
final bool folder;
const FileSelector(
{required this.placeholder,
required this.windowTitle,
required this.controller,
required this.validator,
required this.folder,
this.label,
this.extension,
this.validatorMode,
this.allowNavigator = true,
Key? key})
: assert(folder || extension != null, "Missing extension for file selector"),
super(key: key);
@override
State<FileSelector> createState() => _FileSelectorState();
}
class _FileSelectorState extends State<FileSelector> {
bool _selecting = false;
@override
Widget build(BuildContext context) {
return widget.label != null ? InfoLabel(
label: widget.label!,
child: _buildBody,
) : _buildBody;
}
Widget get _buildBody => Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextFormBox(
controller: widget.controller,
placeholder: widget.placeholder,
validator: widget.validator,
autovalidateMode: widget.validatorMode ?? AutovalidateMode.onUserInteraction
)
),
if (widget.allowNavigator)
const SizedBox(width: 16.0),
if (widget.allowNavigator)
Padding(
padding: const EdgeInsets.only(bottom: 21.0),
child: Button(
onPressed: _onPressed,
child: const Icon(FluentIcons.open_folder_horizontal)
)
)
],
);
void _onPressed() {
if(_selecting){
showMessage("Folder selector is already opened");
return;
}
_selecting = true;
if(widget.folder) {
compute(openFolderPicker, widget.windowTitle)
.then((value) => widget.controller.text = value ?? widget.controller.text)
.then((_) => _selecting = false);
return;
}
compute(openFilePicker, widget.extension!)
.then((value) => widget.controller.text = value ?? widget.controller.text)
.then((_) => _selecting = false);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:fluent_ui/fluent_ui.dart';
class FluentCard extends StatelessWidget {
final Widget child;
const FluentCard({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) => Mica(
elevation: 1,
child: Card(
backgroundColor: FluentTheme.of(context).menuColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4.0)),
child: child
)
);
}

View File

@@ -0,0 +1,90 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/ui/widget/shared/fluent_card.dart';
class SettingTile extends StatefulWidget {
static const double kDefaultContentWidth = 200.0;
static const double kDefaultSpacing = 8.0;
final String title;
final String subtitle;
final Widget? content;
final double? contentWidth;
final List<Widget>? expandedContent;
final double expandedContentSpacing;
final bool isChild;
const SettingTile(
{Key? key,
required this.title,
required this.subtitle,
this.content,
this.contentWidth = kDefaultContentWidth,
this.expandedContentSpacing = kDefaultSpacing,
this.expandedContent,
this.isChild = false})
: super(key: key);
@override
State<SettingTile> createState() => _SettingTileState();
}
class _SettingTileState extends State<SettingTile> {
@override
Widget build(BuildContext context) {
if(widget.expandedContent == null){
return _contentCard;
}
return Mica(
elevation: 1,
child: Expander(
initiallyExpanded: true,
contentBackgroundColor: FluentTheme.of(context).menuColor,
headerShape: (open) => const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(4.0)),
),
header: _header,
headerHeight: 72,
trailing: _trailing,
content: _content
),
);
}
Widget get _content {
var contents = widget.expandedContent!;
var items = List.generate(contents.length * 2, (index) => index % 2 == 0 ? contents[index ~/ 2] : SizedBox(height: widget.expandedContentSpacing));
return Column(
children: items
);
}
Widget get _trailing => SizedBox(
width: widget.contentWidth,
child: widget.content
);
Widget get _header => ListTile(
title: Text(widget.title),
subtitle: Text(widget.subtitle)
);
Widget get _contentCard {
if (widget.isChild) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _contentCardBody
);
}
return FluentCard(
child: _contentCardBody,
);
}
Widget get _contentCardBody => ListTile(
title: Text(widget.title),
subtitle: Text(widget.subtitle),
trailing: _trailing
);
}

View File

@@ -0,0 +1,27 @@
import 'package:fluent_ui/fluent_ui.dart';
class SmartCheckBox extends StatefulWidget {
final CheckboxController controller;
final Widget? content;
const SmartCheckBox({Key? key, required this.controller, this.content}) : super(key: key);
@override
State<SmartCheckBox> createState() => _SmartCheckBoxState();
}
class _SmartCheckBoxState extends State<SmartCheckBox> {
@override
Widget build(BuildContext context) {
return Checkbox(
checked: widget.controller.value,
onChanged: (checked) => setState(() => widget.controller.value = checked ?? false),
content: widget.content
);
}
}
class CheckboxController {
bool value;
CheckboxController({this.value = false});
}

View File

@@ -0,0 +1,41 @@
import 'package:fluent_ui/fluent_ui.dart';
class SmartInput extends StatelessWidget {
final String? label;
final String placeholder;
final TextEditingController controller;
final TextInputType type;
final bool enabled;
final VoidCallback? onTap;
final bool readOnly;
final AutovalidateMode validatorMode;
final String? Function(String?)? validator;
const SmartInput(
{Key? key,
required this.placeholder,
required this.controller,
this.label,
this.onTap,
this.enabled = true,
this.readOnly = false,
this.type = TextInputType.text,
this.validatorMode = AutovalidateMode.disabled,
this.validator})
: super(key: key);
@override
Widget build(BuildContext context) {
return TextFormBox(
enabled: enabled,
controller: controller,
header: label,
keyboardType: type,
placeholder: placeholder,
onTap: onTap,
readOnly: readOnly,
autovalidateMode: validatorMode,
validator: validator
);
}
}