<feat: New project structure>

<feat: New release>
This commit is contained in:
Alessandro Autiero
2023-09-02 15:34:15 +02:00
parent 64b33102f4
commit b41e22adeb
953 changed files with 1373072 additions and 0 deletions

166
gui/lib/main.dart Normal file
View File

@@ -0,0 +1,166 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/interactive/error.dart';
import 'package:reboot_launcher/src/controller/build_controller.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/interactive/server.dart';
import 'package:reboot_launcher/src/page/home_page.dart';
import 'package:reboot_launcher/src/util/watch.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:system_theme/system_theme.dart';
import 'package:window_manager/window_manager.dart';
import 'package:url_protocol/url_protocol.dart';
const double kDefaultWindowWidth = 1536;
const double kDefaultWindowHeight = 1024;
const String kCustomUrlSchema = "reboot";
void main() async {
runZonedGuarded(() async {
await installationDirectory.create(recursive: true);
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey
);
WidgetsFlutterBinding.ensureInitialized();
await SystemTheme.accentColor.load();
var storageError = await _initStorage();
var urlError = await _initUrlHandler();
var windowError = await _initWindow();
var observerError = _initObservers();
runApp(const RebootApplication());
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => _handleErrors([urlError, storageError, windowError, observerError]));
},
(error, stack) => onError(error, stack, false),
zoneSpecification: ZoneSpecification(
handleUncaughtError: (self, parent, zone, error, stacktrace) => onError(error, stacktrace, false)
));
}
void _handleErrors(List<Object?> errors) => errors.where((element) => element != null).forEach((element) => onError(element, null, false));
Future<Object?> _initUrlHandler() async {
try {
registerProtocolHandler(kCustomUrlSchema, arguments: ['%s']);
var appLinks = AppLinks();
var initialUrl = await appLinks.getInitialAppLink();
if(initialUrl != null) {
}
var gameController = Get.find<GameController>();
var matchmakerController = Get.find<MatchmakerController>();
appLinks.uriLinkStream.listen((uri) {
var uuid = _parseCustomUrl(uri);
var server = gameController.findServerById(uuid);
if(server != null) {
matchmakerController.joinServer(server);
return;
}
showMessage(
"No server found: invalid or expired link",
duration: snackbarLongDuration,
severity: InfoBarSeverity.error
);
});
return null;
}catch(error) {
return error;
}
}
String _parseCustomUrl(Uri uri) => uri.host;
Future<Object?> _initWindow() async {
try {
await windowManager.ensureInitialized();
var settingsController = Get.find<SettingsController>();
var size = Size(settingsController.width, settingsController.height);
await windowManager.setSize(size);
if(settingsController.offsetX != null && settingsController.offsetY != null){
await windowManager.setPosition(Offset(settingsController.offsetX!, settingsController.offsetY!));
}else {
await windowManager.setAlignment(Alignment.center);
}
return null;
}catch(error) {
return error;
}
}
Object? _initObservers() {
try {
var gameController = Get.find<GameController>();
var gameInstance = gameController.instance.value;
gameInstance?.startObserver();
var hostingController = Get.find<HostingController>();
var hostingInstance = hostingController.instance.value;
hostingInstance?.startObserver();
return null;
}catch(error) {
return error;
}
}
Future<Object?> _initStorage() async {
try {
await GetStorage("reboot_game", settingsDirectory.path).initStorage;
await GetStorage("reboot_authenticator", settingsDirectory.path).initStorage;
await GetStorage("reboot_matchmaker", settingsDirectory.path).initStorage;
await GetStorage("reboot_update", settingsDirectory.path).initStorage;
await GetStorage("reboot_settings", settingsDirectory.path).initStorage;
await GetStorage("reboot_hosting", settingsDirectory.path).initStorage;
Get.put(GameController());
Get.put(AuthenticatorController());
Get.put(MatchmakerController());
Get.put(BuildController());
Get.put(SettingsController());
Get.put(HostingController());
var updateController = UpdateController();
Get.put(updateController);
updateController.update();
return null;
}catch(error) {
print(error);
return error;
}
}
class RebootApplication extends StatefulWidget {
const RebootApplication({Key? key}) : super(key: key);
@override
State<RebootApplication> createState() => _RebootApplicationState();
}
class _RebootApplicationState extends State<RebootApplication> {
@override
Widget build(BuildContext context) => FluentApp(
title: "Reboot Launcher",
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
color: SystemTheme.accentColor.accent.toAccentColor(),
darkTheme: _createTheme(Brightness.dark),
theme: _createTheme(Brightness.light),
home: const HomePage()
);
FluentThemeData _createTheme(Brightness brightness) => FluentThemeData(
brightness: brightness,
accentColor: SystemTheme.accentColor.accent.toAccentColor(),
visualDensity: VisualDensity.standard,
scaffoldBackgroundColor: Colors.transparent
);
}

View File

@@ -0,0 +1,24 @@
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/server_controller.dart';
class AuthenticatorController extends ServerController {
AuthenticatorController() : super();
@override
String get controllerName => "authenticator";
@override
String get storageName => "reboot_authenticator";
@override
String get defaultHost => kDefaultAuthenticatorHost;
@override
String get defaultPort => kDefaultAuthenticatorPort;
@override
Future<bool> get isPortFree => isAuthenticatorPortFree();
@override
Future<bool> freePort() => freeAuthenticatorPort();
}

View File

@@ -0,0 +1,24 @@
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
class BuildController extends GetxController {
List<FortniteBuild>? _builds;
Rxn<FortniteBuild> selectedBuild;
BuildController() : selectedBuild = Rxn();
List<FortniteBuild>? get builds => _builds;
set builds(List<FortniteBuild>? builds) {
_builds = builds;
if(builds == null || builds.isEmpty){
return;
}
selectedBuild.value = builds[0];
}
void reset(){
_builds = null;
selectedBuild.value = null;
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:convert';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
class GameController extends GetxController {
late final String uuid;
late final GetStorage _storage;
late final TextEditingController username;
late final TextEditingController password;
late final TextEditingController customLaunchArgs;
late final Rx<List<FortniteVersion>> versions;
late final Rxn<FortniteVersion> _selectedVersion;
late final RxBool started;
late final RxBool autoStartGameServer;
late final Rxn<Set<Map<String, dynamic>>> servers;
late final Rxn<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);
uuid = _storage.read("uuid") ?? const Uuid().v4();
_storage.write("uuid", uuid);
_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));
customLaunchArgs = TextEditingController(text: _storage.read("custom_launch_args") ?? "");
customLaunchArgs.addListener(() => _storage.write("custom_launch_args", customLaunchArgs.text));
started = RxBool(false);
autoStartGameServer = RxBool(_storage.read("auto_game_server") ?? true);
autoStartGameServer.listen((value) => _storage.write("auto_game_server", value));
var supabase = Supabase.instance.client;
servers = Rxn();
supabase.from('hosts')
.stream(primaryKey: ['id'])
.map((event) => event.where((element) => element["ip"] != null).toSet())
.listen((event) {
if(servers.value == null) {
servers.value = event;
}else {
servers.value?.addAll(event);
}
});
var serializedInstance = _storage.read("instance");
instance = Rxn(serializedInstance != null ? GameInstance.fromJson(jsonDecode(serializedInstance)) : null);
instance.listen((value) => _storage.write("instance", jsonEncode(value?.toJson())));
}
void reset() {
username.text = kDefaultPlayerName;
password.text = "";
customLaunchArgs.text = "";
versions.value = [];
autoStartGameServer.value = true;
instance.value = null;
}
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));
if (selectedVersion?.name == version.name || hasNoVersions) {
selectedVersion = null;
}
}
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.value = version;
_storage.write("version", version?.name);
}
void updateVersion(FortniteVersion version, Function(FortniteVersion) function) {
versions.update((val) => function(version));
}
Map<String, dynamic>? findServerById(String uuid) {
try {
print(uuid);
return servers.value?.firstWhere((element) => element["id"] == uuid);
} on StateError catch(_) {
return null;
}
}
}

View File

@@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
const String kDefaultServerName = "Reboot Game Server";
const String kDefaultDescription = "Just another server";
class HostingController extends GetxController {
late final GetStorage _storage;
late final TextEditingController name;
late final TextEditingController description;
late final TextEditingController password;
late final RxBool showPassword;
late final RxBool discoverable;
late final RxBool started;
late final Rxn<GameInstance> instance;
HostingController() {
_storage = GetStorage("reboot_hosting");
name = TextEditingController(text: _storage.read("name") ?? kDefaultServerName);
name.addListener(() => _storage.write("name", name.text));
description = TextEditingController(text: _storage.read("description") ?? kDefaultDescription);
description.addListener(() => _storage.write("description", description.text));
password = TextEditingController(text: _storage.read("password") ?? "");
password.addListener(() => _storage.write("password", password.text));
discoverable = RxBool(_storage.read("discoverable") ?? true);
discoverable.listen((value) => _storage.write("discoverable", value));
started = RxBool(false);
showPassword = RxBool(false);
var serializedInstance = _storage.read("instance");
instance = Rxn(serializedInstance != null ? GameInstance.fromJson(jsonDecode(serializedInstance)) : null);
instance.listen((value) => _storage.write("instance", jsonEncode(value?.toJson())));
}
void reset() {
name.text = kDefaultServerName;
description.text = kDefaultDescription;
showPassword.value = false;
discoverable.value = false;
started.value = false;
instance.value = null;
}
}

View File

@@ -0,0 +1,30 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/server_controller.dart';
class MatchmakerController extends ServerController {
late final TextEditingController gameServerAddress;
MatchmakerController() : super() {
gameServerAddress = TextEditingController(text: storage.read("game_server_address") ?? kDefaultMatchmakerHost);
gameServerAddress.addListener(() => storage.write("game_server_address", gameServerAddress.text));
}
@override
String get controllerName => "matchmaker";
@override
String get storageName => "reboot_matchmaker";
@override
String get defaultHost => kDefaultMatchmakerHost;
@override
String get defaultPort => kDefaultMatchmakerPort;
@override
Future<bool> get isPortFree => isMatchmakerPortFree();
@override
Future<bool> freePort() => freeMatchmakerPort();
}

View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
import 'package:sync/semaphore.dart';
abstract class ServerController extends GetxController {
late final GetStorage storage;
late final TextEditingController host;
late final TextEditingController port;
late final Rx<ServerType> type;
late final Semaphore semaphore;
late RxBool started;
late RxBool detached;
Process? embeddedServer;
HttpServer? localServer;
HttpServer? remoteServer;
ServerController() {
storage = GetStorage(storageName);
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.name}_host", host.text));
port = TextEditingController(text: _readPort());
port.addListener(() =>
storage.write("${type.value.name}_port", port.text));
detached = RxBool(storage.read("detached") ?? false);
detached.listen((value) => storage.write("detached", value));
semaphore = Semaphore();
}
String get controllerName;
String get storageName;
String get defaultHost;
String get defaultPort;
Future<bool> get isPortFree;
Future<bool> get isPortTaken async => !(await isPortFree);
Future<bool> freePort();
void reset() async {
type.value = ServerType.values.elementAt(0);
for (var type in ServerType.values) {
storage.write("${type.name}_host", null);
storage.write("${type.name}_port", null);
}
host.text = type.value != ServerType.remote ? defaultHost : "";
port.text = defaultPort;
detached.value = false;
}
String _readHost() {
String? value = storage.read("${type.value.name}_host");
return value != null && value.isNotEmpty ? value
: type.value != ServerType.remote ? defaultHost : "";
}
String _readPort() =>
storage.read("${type.value.name}_port") ?? defaultPort;
Stream<ServerResult> start() async* {
try {
var host = this.host.text.trim();
if (host.isEmpty) {
yield ServerResult(ServerResultType.missingHostError);
return;
}
var port = this.port.text.trim();
if (port.isEmpty) {
yield ServerResult(ServerResultType.missingPortError);
return;
}
var portNumber = int.tryParse(port);
if (portNumber == null) {
yield ServerResult(ServerResultType.illegalPortError);
return;
}
if (type() != ServerType.local && await isPortTaken) {
yield ServerResult(ServerResultType.freeingPort);
var result = await freePort();
yield ServerResult(result ? ServerResultType.freePortSuccess : ServerResultType.freePortError);
if(!result) {
return;
}
}
switch(type()){
case ServerType.embedded:
embeddedServer = await startEmbeddedAuthenticator(detached());
break;
case ServerType.remote:
yield ServerResult(ServerResultType.pingingRemote);
var uriResult = await ping(host, port);
if(uriResult == null) {
yield ServerResult(ServerResultType.pingError);
return;
}
remoteServer = await startRemoteAuthenticatorProxy(uriResult);
break;
case ServerType.local:
if(port != defaultPort) {
localServer = await startRemoteAuthenticatorProxy(Uri.parse("http://$defaultHost:$defaultPort"));
}
break;
}
yield ServerResult(ServerResultType.pingingLocal);
var uriResult = await pingSelf(defaultPort);
if(uriResult == null) {
yield ServerResult(ServerResultType.pingError);
return;
}
yield ServerResult(ServerResultType.startSuccess);
started.value = true;
}catch(error, stackTrace) {
yield ServerResult(
ServerResultType.startError,
error: error,
stackTrace: stackTrace
);
}
}
Future<bool> stop() async {
started.value = false;
try{
switch(type()){
case ServerType.embedded:
freePort();
break;
case ServerType.remote:
await remoteServer?.close(force: true);
remoteServer = null;
break;
case ServerType.local:
await localServer?.close(force: true);
localServer = null;
break;
}
return true;
}catch(_){
started.value = true;
return false;
}
}
Stream<ServerResult> restart() async* {
await resetWinNat();
if(started()) {
await stop();
}
yield* start();
}
Stream<ServerResult> toggle() async* {
if(started()) {
await stop();
}else {
yield* start();
}
}
}

View File

@@ -0,0 +1,63 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_common/common.dart';
import 'package:window_manager/window_manager.dart';
class SettingsController extends GetxController {
static const String _kDefaultIp = "127.0.0.1";
late final GetStorage _storage;
late final String originalDll;
late final TextEditingController rebootDll;
late final TextEditingController consoleDll;
late final TextEditingController authDll;
late final RxBool firstRun;
late double width;
late double height;
late double? offsetX;
late double? offsetY;
late double scrollingDistance;
SettingsController() {
_storage = GetStorage("reboot_settings");
rebootDll = _createController("reboot", "reboot.dll");
consoleDll = _createController("console", "console.dll");
authDll = _createController("cobalt", "cobalt.dll");
width = _storage.read("width") ?? kDefaultWindowWidth;
height = _storage.read("height") ?? kDefaultWindowHeight;
offsetX = _storage.read("offset_x");
offsetY = _storage.read("offset_y");
scrollingDistance = 0.0;
firstRun = RxBool(_storage.read("first_run") ?? true);
firstRun.listen((value) => _storage.write("first_run", value));
}
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() async {
var size = await windowManager.getSize();
_storage.write("width", size.width);
_storage.write("height", size.height);
}
void saveWindowOffset(Offset position) {
_storage.write("offset_x", position.dx);
_storage.write("offset_y", position.dy);
}
void reset(){
rebootDll.text = _controllerDefaultPath("reboot.dll");
consoleDll.text = _controllerDefaultPath("console.dll");
authDll.text = _controllerDefaultPath("cobalt.dll");
firstRun.value = true;
writeMatchmakingIp(_kDefaultIp);
}
String _controllerDefaultPath(String name) => "${assetsDirectory.path}\\dlls\\$name";
}

View File

@@ -0,0 +1,47 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
class UpdateController {
late final GetStorage _storage;
late final RxnInt timestamp;
late final Rx<UpdateStatus> status;
late final Rx<UpdateTimer> timer;
late final TextEditingController url;
UpdateController() {
_storage = GetStorage("reboot_update");
timestamp = RxnInt(_storage.read("ts"));
timestamp.listen((value) => _storage.write("ts", value));
var timerIndex = _storage.read("timer");
timer = Rx(timerIndex == null ? UpdateTimer.never : UpdateTimer.values.elementAt(timerIndex));
timer.listen((value) => _storage.write("timer", value.index));
url = TextEditingController(text: _storage.read("update_url") ?? rebootDownloadUrl);
url.addListener(() => _storage.write("update_url", url.text));
status = Rx(UpdateStatus.waiting);
}
Future<void> update() async {
if(timer.value == UpdateTimer.never) {
status.value = UpdateStatus.success;
return;
}
try {
timestamp.value = await downloadRebootDll(url.text, timestamp.value);
status.value = UpdateStatus.success;
}catch(_) {
status.value = UpdateStatus.error;
rethrow;
}
}
void reset() {
timestamp.value = null;
timer.value = UpdateTimer.never;
url.text = rebootDownloadUrl;
status.value = UpdateStatus.waiting;
update();
}
}

View File

@@ -0,0 +1,256 @@
import 'package:clipboard/clipboard.dart';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:fluent_ui/fluent_ui.dart' as fluent show showDialog;
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/page/home_page.dart';
import 'dialog_button.dart';
Future<T?> showDialog<T extends Object?>({required WidgetBuilder builder}) => fluent.showDialog(
context: pageKey.currentContext!,
useRootNavigator: false,
builder: builder
);
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) => 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,55 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
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) => widget.type == ButtonType.only ? _onlyButton : _button;
SizedBox get _onlyButton => SizedBox(
width: double.infinity,
child: _button
);
Widget get _button => widget.type == ButtonType.primary ? _primaryButton : _secondaryButton;
Widget get _primaryButton {
return Button(
onPressed: widget.onTap!,
child: Text(widget.text!),
);
}
Widget get _secondaryButton {
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,38 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:reboot_launcher/src/page/home_page.dart';
import 'package:sync/semaphore.dart';
Semaphore _semaphore = Semaphore();
OverlayEntry? _lastOverlay;
void showMessage(String text, {InfoBarSeverity severity = InfoBarSeverity.info, bool loading = false, Duration? duration = snackbarShortDuration}) {
try {
_semaphore.acquire();
if(_lastOverlay?.mounted == true) {
_lastOverlay?.remove();
}
var pageIndexValue = pageIndex.value;
_lastOverlay = showSnackbar(
pageKey.currentContext!,
InfoBar(
title: Text(text),
isLong: true,
isIconVisible: true,
content: SizedBox(
width: double.infinity,
child: loading ? const ProgressBar() : const SizedBox()
),
severity: severity
),
margin: EdgeInsets.only(
left: 330.0,
right: 16.0,
bottom: pageIndexValue == 0 || pageIndexValue == 1 || pageIndexValue == 3 || pageIndexValue == 4 ? 72 : 16
),
duration: duration
);
}finally {
_semaphore.release();
}
}

View File

@@ -0,0 +1,36 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:reboot_launcher/src/page/home_page.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
String? lastError;
void onError(Object? exception, StackTrace? stackTrace, bool framework) {
if(exception == null){
return;
}
if(pageKey.currentContext == null || pageKey.currentState?.mounted == false){
return;
}
if(lastError == exception.toString()){
return;
}
lastError = exception.toString();
var route = ModalRoute.of(pageKey.currentContext!);
if(route != null && !route.isCurrent){
Navigator.of(pageKey.currentContext!).pop(false);
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => showDialog(
builder: (context) =>
ErrorDialog(
exception: exception,
stackTrace: stackTrace,
errorMessageBuilder: (exception) => framework ? "An error was thrown by Flutter: $exception" : "An uncaught error was thrown: $exception"
)
));
}

View File

@@ -0,0 +1,86 @@
import 'package:reboot_common/common.dart';
import '../dialog/dialog.dart';
const String _unsupportedServerError = "The build you are currently using is not supported by Reboot. "
"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 = "An unknown occurred while launching Fortnite. "
"Some critical files could be missing in your installation. "
"Download the build again from the launcher, not locally, or from a different source. "
"Alternatively, something could have gone wrong in the launcher. ";
Future<void> showBrokenError() async {
showDialog(
builder: (context) => const InfoDialog(
text: "The backend server is not working correctly"
)
);
}
Future<void> showMissingDllError(String name) async {
showDialog(
builder: (context) => InfoDialog(
text: "$name dll is not a valid dll, fix it in the settings tab"
)
);
}
Future<void> showTokenErrorFixable() async {
showDialog(
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(
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(
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(
builder: (context) => InfoDialog(
text: server ? _unsupportedServerError : _corruptedBuildError
)
);
return;
}
showDialog(
builder: (context) => ErrorDialog(
exception: error,
stackTrace: stackTrace,
errorMessageBuilder: (exception) => "${_corruptedBuildError}Error message: $exception"
)
);
}
Future<void> showMissingBuildError(FortniteVersion version) async {
showDialog(
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,83 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:flutter/material.dart' show Icons;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
final GameController _gameController = Get.find<GameController>();
Future<bool> showProfileForm(BuildContext context) async{
var showPassword = RxBool(false);
var oldUsername = _gameController.username.text;
var showPasswordTrailing = RxBool(oldUsername.isNotEmpty);
var oldPassword = _gameController.password.text;
var result = await showDialog<bool?>(
builder: (context) => Obx(() => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: "Username/Email",
child: TextFormBox(
placeholder: "Type your username or email",
controller: _gameController.username,
autovalidateMode: AutovalidateMode.always,
enableSuggestions: true,
autofocus: true,
autocorrect: false,
)
),
const SizedBox(height: 16.0),
InfoLabel(
label: "Password",
child: TextFormBox(
placeholder: "Type your password, if you have one",
controller: _gameController.password,
autovalidateMode: AutovalidateMode.always,
obscureText: !showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
suffix: Button(
onPressed: () => showPassword.value = !showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
showPassword.value ? Icons.visibility_off : Icons.visibility,
color: showPasswordTrailing.value ? null : Colors.transparent
),
)
)
),
const SizedBox(height: 8.0)
],
),
buttons: [
DialogButton(
text: "Cancel",
type: ButtonType.secondary
),
DialogButton(
text: "Save",
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop(true);
}
)
]
))
) ?? false;
if(result) {
return true;
}
_gameController.username.text = oldUsername;
_gameController.password.text = oldPassword;
return false;
}

View File

@@ -0,0 +1,222 @@
import 'dart:async';
import 'package:clipboard/clipboard.dart';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:flutter/material.dart' show Icons;
import 'package:get/get_rx/src/rx_types/rx_types.dart';
import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/controller/server_controller.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/page/home_page.dart';
import 'package:reboot_launcher/src/util/cryptography.dart';
extension ServerControllerDialog on ServerController {
Future<bool> restartInteractive() async {
var stream = restart();
return await _handleStream(stream, false);
}
Future<bool> toggleInteractive([bool showSuccessMessage = true]) async {
var stream = toggle();
return await _handleStream(stream, showSuccessMessage);
}
Future<bool> _handleStream(Stream<ServerResult> stream, bool showSuccessMessage) async {
var completer = Completer<bool>();
stream.listen((event) {
switch (event.type) {
case ServerResultType.missingHostError:
showMessage(
"Cannot launch game: missing hostname in $controllerName configuration",
severity: InfoBarSeverity.error
);
break;
case ServerResultType.missingPortError:
showMessage(
"Cannot launch game: missing port in $controllerName configuration",
severity: InfoBarSeverity.error
);
break;
case ServerResultType.illegalPortError:
showMessage(
"Cannot launch game: invalid port in $controllerName configuration",
severity: InfoBarSeverity.error
);
break;
case ServerResultType.freeingPort:
case ServerResultType.freePortSuccess:
case ServerResultType.freePortError:
showMessage(
"Message",
loading: event.type == ServerResultType.freeingPort,
severity: event.type == ServerResultType.freeingPort ? InfoBarSeverity.info : event.type == ServerResultType.freePortSuccess ? InfoBarSeverity.success : InfoBarSeverity.error
);
break;
case ServerResultType.pingingRemote:
showMessage(
"Pinging remote server...",
severity: InfoBarSeverity.info,
loading: true,
duration: const Duration(seconds: 10)
);
break;
case ServerResultType.pingingLocal:
showMessage(
"Pinging ${type().name} server...",
severity: InfoBarSeverity.info,
loading: true,
duration: const Duration(seconds: 10)
);
break;
case ServerResultType.pingError:
showMessage(
"Cannot ping ${type().name} server",
severity: InfoBarSeverity.error
);
break;
case ServerResultType.startSuccess:
if(showSuccessMessage) {
showMessage(
"The $controllerName was started successfully",
severity: InfoBarSeverity.success
);
}
completer.complete(true);
break;
case ServerResultType.startError:
showMessage(
"An error occurred while starting the $controllerName: ${event.error ?? "unknown error"}",
severity: InfoBarSeverity.error
);
break;
}
if(event.type.isError) {
completer.complete(false);
}
});
var result = await completer.future;
if(result && type() == ServerType.embedded) {
watchProcess(embeddedServer!.pid).then((value) {
if(started()) {
pageIndex.value = 3;
started.value = false;
WidgetsBinding.instance.addPostFrameCallback((_) => showMessage(
"The $controllerName was terminated unexpectedly: if this wasn't intentional, file a bug report",
severity: InfoBarSeverity.warning,
duration: snackbarLongDuration
));
}
});
}
return result;
}
}
extension MatchmakerControllerExtension on MatchmakerController {
Future<void> joinServer(Map<String, dynamic> entry) async {
var hashedPassword = entry["password"];
var hasPassword = hashedPassword != null;
var embedded = type.value == ServerType.embedded;
var author = entry["author"];
var encryptedIp = entry["ip"];
if(!hasPassword) {
_onSuccess(embedded, encryptedIp, author);
return;
}
var confirmPassword = await _askForPassword();
if(confirmPassword == null) {
return;
}
if(!checkPassword(confirmPassword, hashedPassword)) {
showMessage(
"Wrong password: please try again",
duration: snackbarLongDuration,
severity: InfoBarSeverity.error
);
return;
}
var decryptedIp = aes256Decrypt(encryptedIp, confirmPassword);
_onSuccess(embedded, decryptedIp, author);
}
Future<String?> _askForPassword() async {
var confirmPasswordController = TextEditingController();
var showPassword = RxBool(false);
var showPasswordTrailing = RxBool(false);
return await showDialog<String?>(
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: "Password",
child: Obx(() => TextFormBox(
placeholder: "Type the server's password",
controller: confirmPasswordController,
autovalidateMode: AutovalidateMode.always,
obscureText: !showPassword.value,
enableSuggestions: false,
autofocus: true,
autocorrect: false,
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
suffix: Button(
onPressed: () => showPasswordTrailing.value = !showPasswordTrailing.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
showPassword.value ? Icons.visibility_off : Icons.visibility,
color: showPassword.value ? null : Colors.transparent
),
)
))
),
const SizedBox(height: 8.0)
],
),
buttons: [
DialogButton(
text: "Cancel",
type: ButtonType.secondary
),
DialogButton(
text: "Confirm",
type: ButtonType.primary,
onTap: () => Navigator.of(context).pop(confirmPasswordController.text)
)
]
)
);
}
void _onSuccess(bool embedded, String decryptedIp, String author) {
if(embedded) {
gameServerAddress.text = decryptedIp;
pageIndex.value = 0;
}else {
FlutterClipboard.controlC(decryptedIp);
}
WidgetsBinding.instance.addPostFrameCallback((_) => showMessage(
embedded ? "You joined $author's server successfully!" : "Copied IP to the clipboard",
duration: snackbarLongDuration,
severity: InfoBarSeverity.success
));
}
}

View File

@@ -0,0 +1,131 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/widget/server/start_button.dart';
import 'package:reboot_launcher/src/widget/server/type_selector.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
class AuthenticatorPage extends StatefulWidget {
const AuthenticatorPage({Key? key}) : super(key: key);
@override
State<AuthenticatorPage> createState() => _AuthenticatorPageState();
}
class _AuthenticatorPageState extends State<AuthenticatorPage> with AutomaticKeepAliveClientMixin {
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Obx(() => Column(
children: [
Expanded(
child: ListView(
children: [
SettingTile(
title: "Authenticator configuration",
subtitle: "This section contains the authenticator's configuration",
content: const ServerTypeSelector(
authenticator: true
),
expandedContent: [
if(_authenticatorController.type.value == ServerType.remote)
SettingTile(
title: "Host",
subtitle: "The hostname of the authenticator",
isChild: true,
content: TextFormBox(
placeholder: "Host",
controller: _authenticatorController.host,
readOnly: !_isRemote
)
),
if(_authenticatorController.type.value != ServerType.embedded)
SettingTile(
title: "Port",
subtitle: "The port of the authenticator",
isChild: true,
content: TextFormBox(
placeholder: "Port",
controller: _authenticatorController.port,
readOnly: !_isRemote
)
),
if(_authenticatorController.type.value == ServerType.embedded)
SettingTile(
title: "Detached",
subtitle: "Whether the embedded authenticator should be started as a separate process, useful for debugging",
contentWidth: null,
isChild: true,
content: Obx(() => ToggleSwitch(
checked: _authenticatorController.detached(),
onChanged: (value) => _authenticatorController.detached.value = value
))
),
],
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Installation directory",
subtitle: "Opens the folder where the embedded authenticator is located",
content: Button(
onPressed: () => launchUrl(authenticatorDirectory.uri),
child: const Text("Show Files")
)
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Reset authenticator",
subtitle: "Resets the authenticator's settings to their default values",
content: Button(
onPressed: () => showDialog(
builder: (context) => InfoDialog(
text: "Do you want to reset all the setting in this tab to their default values? This action is irreversible",
buttons: [
DialogButton(
type: ButtonType.secondary,
text: "Close",
),
DialogButton(
type: ButtonType.primary,
text: "Reset",
onTap: () {
_authenticatorController.reset();
Navigator.of(context).pop();
},
)
],
)
),
child: const Text("Reset"),
)
)
]
),
),
const SizedBox(
height: 8.0,
),
const ServerButton(
authenticator: true
)
],
));
}
bool get _isRemote => _authenticatorController.type.value == ServerType.remote;
}

View File

@@ -0,0 +1,265 @@
import 'dart:async';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/interactive/server.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:skeletons/skeletons.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
class BrowsePage extends StatefulWidget {
const BrowsePage({Key? key}) : super(key: key);
@override
State<BrowsePage> createState() => _BrowsePageState();
}
class _BrowsePageState extends State<BrowsePage> with AutomaticKeepAliveClientMixin {
final GameController _gameController = Get.find<GameController>();
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
final TextEditingController _filterController = TextEditingController();
final StreamController<String> _filterControllerStream = StreamController();
@override
Widget build(BuildContext context) {
super.build(context);
return FutureBuilder(
future: Future.delayed(const Duration(seconds: 1)), // Fake delay to show loading
builder: (context, futureSnapshot) => Obx(() {
var ready = futureSnapshot.connectionState == ConnectionState.done;
var data = _gameController.servers.value;
if(ready && data?.isEmpty == true) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"No servers are available right now",
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
"Host a server yourself or come back later",
style: FluentTheme.of(context).typography.body
),
],
);
}
return Column(
children: [
_buildSearchBar(ready),
const SizedBox(
height: 16,
),
Expanded(
child: StreamBuilder<String?>(
stream: _filterControllerStream.stream,
builder: (context, filterSnapshot) {
var items = _getItems(data, filterSnapshot.data, ready);
var itemsCount = items != null ? items.length * 2 : null;
if(itemsCount == 0) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"No results found",
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
"No server matches your query",
style: FluentTheme.of(context).typography.body
),
],
);
}
return ListView.builder(
itemCount: itemsCount,
itemBuilder: (context, index) {
if(index % 2 != 0) {
return const SizedBox(
height: 8.0
);
}
var entry = _getItem(index ~/ 2, items);
if(!ready || entry == null) {
return const SettingTile(
content: SkeletonAvatar(
style: SkeletonAvatarStyle(
height: 32,
width: 64
),
)
);
}
var hasPassword = entry["password"] != null;
return SettingTile(
title: "${_formatName(entry)}${entry["author"]}",
subtitle: "${_formatDescription(entry)}${_formatVersion(entry)}",
content: Button(
onPressed: () => _matchmakerController.joinServer(entry),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if(hasPassword)
const Icon(FluentIcons.lock),
if(hasPassword)
const SizedBox(width: 8.0),
Text(_matchmakerController.type.value == ServerType.embedded ? "Join Server" : "Copy IP"),
],
),
)
);
}
);
}
),
)
],
);
}
),
);
}
Set<Map<String, dynamic>>? _getItems(Set<Map<String, dynamic>>? data, String? filter, bool ready) {
if (!ready) {
return null;
}
if (data == null) {
return null;
}
return data.where((entry) => _isValidItem(entry, filter)).toSet();
}
bool _isValidItem(Map<String, dynamic> entry, String? filter) =>
(entry["discoverable"] ?? false) && (filter == null || _filterServer(entry, filter));
bool _filterServer(Map<String, dynamic> element, String filter) {
String? id = element["id"];
if(id?.toLowerCase().contains(filter) == true) {
return true;
}
var uri = Uri.tryParse(filter);
if(uri != null && id?.toLowerCase().contains(uri.host.toLowerCase()) == true) {
return true;
}
String? name = element["name"];
if(name?.toLowerCase().contains(filter) == true) {
return true;
}
String? author = element["author"];
if(author?.toLowerCase().contains(filter) == true) {
return true;
}
String? description = element["description"];
if(description?.toLowerCase().contains(filter) == true) {
return true;
}
return false;
}
Widget _buildSearchBar(bool ready) {
if(ready) {
return TextBox(
placeholder: 'Find a server',
controller: _filterController,
onChanged: (value) => _filterControllerStream.add(value),
suffix: _searchBarIcon,
);
}
return const SkeletonLine(
style: SkeletonLineStyle(
height: 32
)
);
}
Widget get _searchBarIcon => Button(
onPressed: _filterController.text.isEmpty ? null : () {
_filterController.clear();
_filterControllerStream.add("");
},
style: ButtonStyle(
backgroundColor: _filterController.text.isNotEmpty ? null : ButtonState.all(Colors.transparent),
border: _filterController.text.isNotEmpty ? null : ButtonState.all(const BorderSide(color: Colors.transparent))
),
child: _searchBarIconData
);
Widget get _searchBarIconData {
var color = FluentTheme.of(context).resources.textFillColorPrimary;
if (_filterController.text.isNotEmpty) {
return Icon(
FluentIcons.clear,
size: 8.0,
color: color
);
}
return Transform.flip(
flipX: true,
child: Icon(
FluentIcons.search,
size: 12.0,
color: color
),
);
}
Map<String, dynamic>? _getItem(int index, Set? data) {
if(data == null) {
return null;
}
if (index >= data.length) {
return null;
}
return data.elementAt(index);
}
String _formatName(Map<String, dynamic> entry) {
String result = entry['name'];
return result.isEmpty ? kDefaultServerName : result;
}
String _formatDescription(Map<String, dynamic> entry) {
String result = entry['description'];
return result.isEmpty ? kDefaultDescription : result;
}
String _formatVersion(Map<String, dynamic> entry) {
var version = entry['version'];
var versionSplit = version.indexOf("-");
var minimalVersion = version = versionSplit != -1 ? version.substring(0, versionSplit) : version;
String result = minimalVersion.endsWith(".0") ? minimalVersion.substring(0, minimalVersion.length - 2) : minimalVersion;
if(result.toLowerCase().startsWith("fortnite ")) {
result = result.substring(0, 10);
}
return "Fortnite $result";
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -0,0 +1,253 @@
import 'dart:collection';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/page/browse_page.dart';
import 'package:reboot_launcher/src/page/authenticator_page.dart';
import 'package:reboot_launcher/src/page/matchmaker_page.dart';
import 'package:reboot_launcher/src/page/play_page.dart';
import 'package:reboot_launcher/src/page/settings_page.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/widget/home/pane.dart';
import 'package:reboot_launcher/src/widget/home/profile.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/widget/os/border.dart';
import 'package:reboot_launcher/src/widget/os/title_bar.dart';
import 'package:window_manager/window_manager.dart';
import 'hosting_page.dart';
import 'info_page.dart';
const int pagesLength = 7;
final RxInt pageIndex = RxInt(0);
final Queue<int> _pagesStack = Queue();
final List<GlobalKey> _pageKeys = List.generate(pagesLength, (index) => GlobalKey());
GlobalKey get pageKey => _pageKeys[pageIndex.value];
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepAliveClientMixin {
static const double _kDefaultPadding = 12.0;
final SettingsController _settingsController = Get.find<SettingsController>();
final GlobalKey _searchKey = GlobalKey();
final FocusNode _searchFocusNode = FocusNode();
final TextEditingController _searchController = TextEditingController();
final RxBool _focused = RxBool(true);
final RxBool _fullScreen = RxBool(false);
@override
bool get wantKeepAlive => true;
@override
void initState() {
windowManager.show();
windowManager.addListener(this);
_searchController.addListener(_onSearch);
super.initState();
}
void _onSearch() {
// TODO: Implement
}
@override
void dispose() {
windowManager.removeListener(this);
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
@override
void onWindowEnterFullScreen() {
_fullScreen.value = true;
}
@override
void onWindowLeaveFullScreen() {
_fullScreen.value = false;
}
@override
void onWindowFocus() {
_focused.value = true;
}
@override
void onWindowBlur() {
_focused.value = false;
}
@override
void onWindowResized() {
_settingsController.saveWindowSize();
super.onWindowResized();
}
@override
void onWindowMoved() {
windowManager.getPosition()
.then((value) => _settingsController.saveWindowOffset(value));
super.onWindowMoved();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Stack(
children: [
Obx(() => NavigationPaneTheme(
data: NavigationPaneThemeData(
backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.9),
),
child: NavigationView(
paneBodyBuilder: (pane, body) => Padding(
padding: const EdgeInsets.all(_kDefaultPadding),
child: SizedBox(
key: pageKey,
child: body
)
),
appBar: NavigationAppBar(
height: 32,
title: _draggableArea,
actions: WindowTitleBar(focused: _focused()),
leading: _backButton,
automaticallyImplyLeading: false,
),
pane: NavigationPane(
selected: pageIndex.value,
onChanged: (index) {
_pagesStack.add(pageIndex.value);
pageIndex.value = index;
},
menuButton: const SizedBox(),
displayMode: PaneDisplayMode.open,
items: _items,
header: const ProfileWidget(),
autoSuggestBox: _autoSuggestBox,
autoSuggestBoxReplacement: const Icon(FluentIcons.search),
),
contentShape: const RoundedRectangleBorder(),
onOpenSearch: () => _searchFocusNode.requestFocus(),
transitionBuilder: (child, animation) => child
)
)),
if (isWin11)
Obx(() => !_fullScreen.value && _focused.value ? const WindowBorder() : const SizedBox())
]
);
}
Widget get _backButton => Obx(() {
pageIndex.value;
return Button(
style: ButtonStyle(
padding: ButtonState.all(const EdgeInsets.only(top: 6.0)),
backgroundColor: ButtonState.all(Colors.transparent),
border: ButtonState.all(const BorderSide(color: Colors.transparent))
),
onPressed: _pagesStack.isEmpty ? null : () => pageIndex.value = _pagesStack.removeLast(),
child: const Icon(FluentIcons.back, size: 12.0),
);
});
GestureDetector get _draggableArea => GestureDetector(
onDoubleTap: () async => await windowManager.isMaximized() ? await windowManager.restore() : await windowManager.maximize(),
onHorizontalDragStart: (event) => windowManager.startDragging(),
onVerticalDragStart: (event) => windowManager.startDragging()
);
Widget get _autoSuggestBox => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TextBox(
key: _searchKey,
controller: _searchController,
placeholder: 'Find a setting',
focusNode: _searchFocusNode,
autofocus: true,
suffix: Button(
onPressed: null,
style: ButtonStyle(
backgroundColor: ButtonState.all(Colors.transparent),
border: ButtonState.all(const BorderSide(color: Colors.transparent))
),
child: Transform.flip(
flipX: true,
child: Icon(
FluentIcons.search,
size: 12.0,
color: FluentTheme.of(context).resources.textFillColorPrimary
),
)
)
),
);
List<NavigationPaneItem> get _items => [
RebootPaneItem(
title: const Text("Play"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/play.png")
),
body: const PlayPage()
),
RebootPaneItem(
title: const Text("Host"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/host.png")
),
body: const HostingPage()
),
RebootPaneItem(
title: const Text("Server Browser"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/browse.png")
),
body: const BrowsePage()
),
RebootPaneItem(
title: const Text("Authenticator"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/auth.png")
),
body: const AuthenticatorPage()
),
RebootPaneItem(
title: const Text("Matchmaker"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/matchmaker.png")
),
body: const MatchmakerPage()
),
RebootPaneItem(
title: const Text("Info"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/info.png")
),
body: const InfoPage()
),
RebootPaneItem(
title: const Text("Settings"),
icon: SizedBox.square(
dimension: 24,
child: Image.asset("assets/images/settings.png")
),
body: const SettingsPage()
),
];
String get searchValue => _searchController.text;
}

View File

@@ -0,0 +1,215 @@
import 'package:clipboard/clipboard.dart';
import 'package:dart_ipify/dart_ipify.dart';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:flutter/material.dart' show Icons;
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/widget/game/start_button.dart';
import 'package:reboot_launcher/src/widget/version/version_selector.dart';
class HostingPage extends StatefulWidget {
const HostingPage({Key? key}) : super(key: key);
@override
State<HostingPage> createState() => _HostingPageState();
}
class _HostingPageState extends State<HostingPage> with AutomaticKeepAliveClientMixin {
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final UpdateController _updateController = Get.find<UpdateController>();
late final RxBool _showPasswordTrailing = RxBool(_hostingController.password.text.isNotEmpty);
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
Expanded(
child: ListView(
children: [
Obx(() => Column(
children: _updateController.status.value != UpdateStatus.error ? [] : [
SizedBox(
width: double.infinity,
child: _updateError
),
const SizedBox(
height: 8.0
),
],
)),
SettingTile(
title: "Game Server",
subtitle: "Provide basic information about your server",
expandedContent: [
SettingTile(
title: "Name",
subtitle: "The name of your game server",
isChild: true,
content: TextFormBox(
placeholder: "Name",
controller: _hostingController.name
)
),
SettingTile(
title: "Description",
subtitle: "The description of your game server",
isChild: true,
content: TextFormBox(
placeholder: "Description",
controller: _hostingController.description
)
),
SettingTile(
title: "Password",
subtitle: "The password of your game server for the server browser",
isChild: true,
content: Obx(() => TextFormBox(
placeholder: "Password",
controller: _hostingController.password,
autovalidateMode: AutovalidateMode.always,
obscureText: !_hostingController.showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty,
suffix: Button(
onPressed: () => _hostingController.showPassword.value = !_hostingController.showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
_hostingController.showPassword.value ? Icons.visibility_off : Icons.visibility,
color: _showPasswordTrailing.value ? null : Colors.transparent
),
)
))
),
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: 8.0,
),
const SettingTile(
title: "Version",
subtitle: "Select the version of Fortnite you want to host",
content: 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,
child: Text("Add build"),
),
isChild: true
),
SettingTile(
title: "Download any version from the cloud",
subtitle: "Download any Fortnite build easily from the cloud",
content: Button(
onPressed: VersionSelector.openDownloadDialog,
child: Text("Download"),
),
isChild: true
)
]
),
const SizedBox(
height: 8.0
),
SettingTile(
title: "Share",
subtitle: "Make it easy for other people to join your server with the options in this section",
expandedContent: [
SettingTile(
title: "Link",
subtitle: "Copies a link for your server to the clipboard (requires the Reboot Launcher)",
isChild: true,
content: Button(
onPressed: () async {
FlutterClipboard.controlC("$kCustomUrlSchema://${_gameController.uuid}");
showMessage(
"Copied your link to the clipboard",
severity: InfoBarSeverity.success
);
},
child: const Text("Copy Link"),
)
),
SettingTile(
title: "Public IP",
subtitle: "Copies your current public IP to the clipboard (doesn't require the Reboot Launcher)",
isChild: true,
content: Button(
onPressed: () async {
try {
showMessage(
"Obtaining your public IP...",
loading: true,
duration: null
);
var ip = await Ipify.ipv4();
FlutterClipboard.controlC(ip);
showMessage(
"Copied your IP to the clipboard",
severity: InfoBarSeverity.success
);
}catch(error) {
showMessage(
"An error occurred while obtaining your public IP: $error",
severity: InfoBarSeverity.error,
duration: snackbarLongDuration
);
}
},
child: const Text("Copy IP"),
)
)
],
)
],
),
),
const SizedBox(
height: 8.0,
),
const LaunchButton(
host: true
)
],
);
}
Widget get _updateError => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: _updateController.update,
child: const InfoBar(
title: Text("The reboot dll couldn't be downloaded: click here to try again"),
severity: InfoBarSeverity.info
),
),
);
}

View File

@@ -0,0 +1,133 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
class InfoPage extends StatefulWidget {
const InfoPage({Key? key}) : super(key: key);
@override
State<InfoPage> createState() => _InfoPageState();
}
class _InfoPageState extends State<InfoPage> with AutomaticKeepAliveClientMixin {
final SettingsController _settingsController = Get.find<SettingsController>();
late final ScrollController _controller;
@override
bool get wantKeepAlive => true;
@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) {
super.build(context);
return Column(
children: [
Expanded(
child: ListView(
children: [
SettingTile(
title: 'What is Project Reboot?',
subtitle: 'Project Reboot allows anyone to easily host a game server for most of Fortnite\'s seasons. '
'The project was started on Discord by Milxnor. '
'The project is no longer being actively maintained.',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: 'What is a game server?',
subtitle: 'When you join a Fortnite Game, your client connects to a game server that allows you to play with others. '
'You can join someone else\'s game server, or host one on your PC by going to the "Host" tab. ',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: 'What is a client?',
subtitle: 'A client is the actual Fortnite game. '
'You can download any version of Fortnite from the launcher in the "Play" tab. '
'You can also import versions from your local PC, but remember that these may be corrupted. '
'If a local version doesn\'t work, try installing it from the launcher before reporting a bug.',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: 'What is an authenticator?',
subtitle: 'An authenticator is a program that handles authentication, parties and voice chats. '
'By default, a LawinV1 server will be started for you to play. '
'You can use also use an authenticator running locally(on your PC) or remotely(on another PC). '
'Changing the authenticator settings can break the client and game server: unless you are an advanced user, do not edit, for any reason, these settings! '
'If you need to restore these settings, go to the "Settings" tab and click on "Restore Defaults". ',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: 'Do I need to update DLLs?',
subtitle: 'No, all the files that the launcher uses are automatically updated. '
'You can use your own DLLs by going to the "Settings" tab, but make sure that they don\'t create a console that reads IO or the launcher will stop working correctly. '
'Unless you are an advanced user, changing these options is not recommended',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: 'Where can I report bugs or ask for new features?',
subtitle: 'Go to the "Settings" tab and click on report bug. '
'Please make sure to be as specific as possible when filing a report as it\'s crucial to make it as easy to fix/implement',
titleStyle: FluentTheme
.of(context)
.typography
.title,
contentWidth: null,
)
],
),
)
],
);
}
}

View File

@@ -0,0 +1,139 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/widget/server/type_selector.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
import 'package:reboot_launcher/src/widget/server/start_button.dart';
class MatchmakerPage extends StatefulWidget {
const MatchmakerPage({Key? key}) : super(key: key);
@override
State<MatchmakerPage> createState() => _MatchmakerPageState();
}
class _MatchmakerPageState extends State<MatchmakerPage> with AutomaticKeepAliveClientMixin {
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
Expanded(
child: ListView(
children: [
Obx(() => SettingTile(
title: "Matchmaker configuration",
subtitle: "This section contains the matchmaker's configuration",
content: const ServerTypeSelector(
authenticator: false
),
expandedContent: [
if(_matchmakerController.type.value == ServerType.remote)
SettingTile(
title: "Host",
subtitle: "The hostname of the matchmaker",
isChild: true,
content: TextFormBox(
placeholder: "Host",
controller: _matchmakerController.host,
readOnly: _matchmakerController.type.value != ServerType.remote
)
),
if(_matchmakerController.type.value != ServerType.embedded)
SettingTile(
title: "Port",
subtitle: "The port of the matchmaker",
isChild: true,
content: TextFormBox(
placeholder: "Port",
controller: _matchmakerController.port,
readOnly: _matchmakerController.type.value != ServerType.remote
)
),
if(_matchmakerController.type.value == ServerType.embedded)
SettingTile(
title: "Game server address",
subtitle: "The address of the game server used by the matchmaker",
isChild: true,
content: TextFormBox(
placeholder: "Address",
controller: _matchmakerController.gameServerAddress
)
),
if(_matchmakerController.type.value == ServerType.embedded)
SettingTile(
title: "Detached",
subtitle: "Whether the embedded matchmaker should be started as a separate process, useful for debugging",
contentWidth: null,
isChild: true,
content: Obx(() => ToggleSwitch(
checked: _matchmakerController.detached.value,
onChanged: (value) => _matchmakerController.detached.value = value
)),
)
]
)),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Installation directory",
subtitle: "Opens the folder where the embedded matchmaker is located",
content: Button(
onPressed: () => launchUrl(authenticatorDirectory.uri),
child: const Text("Show Files")
)
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Reset matchmaker",
subtitle: "Resets the authenticator's settings to their default values",
content: Button(
onPressed: () => showDialog(
builder: (context) => InfoDialog(
text: "Do you want to reset all the setting in this tab to their default values? This action is irreversible",
buttons: [
DialogButton(
type: ButtonType.secondary,
text: "Close",
),
DialogButton(
type: ButtonType.primary,
text: "Reset",
onTap: () {
_matchmakerController.reset();
Navigator.of(context).pop();
},
)
],
)
),
child: const Text("Reset"),
)
)
]
),
),
const SizedBox(
height: 8.0,
),
const ServerButton(
authenticator: false
)
],
);
}
}

View File

@@ -0,0 +1,109 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/page/home_page.dart';
import 'package:reboot_launcher/src/widget/game/start_button.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
import 'package:reboot_launcher/src/widget/version/version_selector.dart';
class PlayPage extends StatefulWidget {
const PlayPage({Key? key}) : super(key: key);
@override
State<PlayPage> createState() => _PlayPageState();
}
class _PlayPageState extends State<PlayPage> {
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
final HostingController _hostingController = Get.find<HostingController>();
late final RxBool _selfServer;
@override
void initState() {
_selfServer = RxBool(_isLocalPlay);
_matchmakerController.gameServerAddress.addListener(() => _selfServer.value = _isLocalPlay);
_hostingController.started.listen((_) => _selfServer.value = _isLocalPlay);
super.initState();
}
bool get _isLocalPlay => isLocalHost(_matchmakerController.gameServerAddress.text)
&& !_hostingController.started.value;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView(
children: [
const SettingTile(
title: "Version",
subtitle: "Select the version of Fortnite you want to host",
content: 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,
child: Text("Add build"),
),
isChild: true
),
SettingTile(
title: "Download any version from the cloud",
subtitle: "Download any Fortnite build easily from the cloud",
content: Button(
onPressed: VersionSelector.openDownloadDialog,
child: Text("Download"),
),
isChild: true
)
]
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Game Server",
subtitle: "Helpful shortcuts to find the server where you want to play",
expandedContent: [
SettingTile(
title: "Host a server",
subtitle: "Do you want to play with your friends? Host a server for them!",
content: Button(
onPressed: () => pageIndex.value = 1,
child: const Text("Host")
),
isChild: true
),
SettingTile(
title: "Join a server",
subtitle: "Find a server where you can play on the launcher's server browser",
content: Button(
onPressed: () => pageIndex.value = 2,
child: const Text("Browse")
),
isChild: true
)
]
),
],
)
),
const SizedBox(
height: 8.0,
),
const LaunchButton(
startLabel: 'Launch Fortnite',
stopLabel: 'Close Fortnite',
host: false
)
]
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/build_controller.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({Key? key}) : super(key: key);
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> with AutomaticKeepAliveClientMixin {
final BuildController _buildController = Get.find<BuildController>();
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
final SettingsController _settingsController = Get.find<SettingsController>();
final UpdateController _updateController = Get.find<UpdateController>();
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
children: [
SettingTile(
title: "Client settings",
subtitle: "This section contains the dlls used to make the Fortnite client work",
expandedContent: [
_createFileSetting(
title: "Unreal engine console",
description: "This file is injected to unlock the Unreal Engine Console",
controller: _settingsController.consoleDll
),
_createFileSetting(
title: "Authentication patcher",
description: "This file is injected to redirect all HTTP requests to the launcher's authenticator",
controller: _settingsController.authDll
),
SettingTile(
title: "Custom launch arguments",
subtitle: "Additional arguments to use when launching the game",
isChild: true,
content: TextFormBox(
placeholder: "Arguments...",
controller: _gameController.customLaunchArgs,
)
),
],
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Server settings",
subtitle: "This section contains settings related to the game server implementation",
expandedContent: [
_createFileSetting(
title: "Game server",
description: "This file is injected to create a game server & host matches",
controller: _settingsController.rebootDll
),
SettingTile(
title: "Update mirror",
subtitle: "The URL used to update the game server dll",
content: TextFormBox(
placeholder: "URL",
controller: _updateController.url,
validator: checkUpdateUrl
),
isChild: true
),
SettingTile(
title: "Update timer",
subtitle: "Determines when the game server dll should be updated",
content: Obx(() => DropDownButton(
leading: Text(_updateController.timer.value.text),
items: UpdateTimer.values.map((entry) => MenuFlyoutItem(
text: Text(entry.text),
onPressed: () => _updateController.timer.value = entry
)).toList()
)),
isChild: true
),
],
),
const SizedBox(
height: 8.0,
),
SettingTile(
title: "Launcher utilities",
subtitle: "This section contains handy settings for the launcher",
expandedContent: [
SettingTile(
title: "Installation directory",
subtitle: "Opens the installation directory",
isChild: true,
content: Button(
onPressed: () => launchUrl(installationDirectory.uri),
child: const Text("Show Files"),
)
),
SettingTile(
title: "Create a bug report",
subtitle: "Help me fix bugs by reporting them",
isChild: true,
content: Button(
onPressed: () => launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/issues")),
child: const Text("Report a bug"),
)
),
SettingTile(
title: "Reset settings",
subtitle: "Resets the launcher's settings to their default values",
isChild: true,
content: Button(
onPressed: () => showDialog(
builder: (context) => InfoDialog(
text: "Do you want to reset all the launcher's settings to their default values? This action is irreversible",
buttons: [
DialogButton(
type: ButtonType.secondary,
text: "Close",
),
DialogButton(
type: ButtonType.primary,
text: "Reset",
onTap: () {
_buildController.reset();
_gameController.reset();
_hostingController.reset();
_authenticatorController.reset();
_settingsController.reset();
_updateController.reset();
Navigator.of(context).pop();
},
)
],
)
),
child: const Text("Reset"),
)
)
],
),
]
);
}
Widget _createFileSetting({required String title, required String description, required TextEditingController controller}) => SettingTile(
title: title,
subtitle: description,
content: FileSelector(
placeholder: "Path",
windowTitle: "Select a file",
controller: controller,
validator: checkDll,
extension: "dll",
folder: false
),
isChild: true
);
}
extension _UpdateTimerExtension on UpdateTimer {
String get text => this == UpdateTimer.never ? "Never" : "Every $name";
}

View File

@@ -0,0 +1,85 @@
import 'dart:io';
import 'package:reboot_common/common.dart';
String? checkVersion(String? text, List<FortniteVersion> versions) {
if (text == null || text.isEmpty) {
return 'Empty version name';
}
if (versions.any((element) => element.name == text)) {
return 'This version already exists';
}
return null;
}
String? checkChangeVersion(String? text) {
if (text == null || text.isEmpty) {
return 'Empty version name';
}
return null;
}
String? checkGameFolder(text) {
if (text == null || text.isEmpty) {
return 'Empty game path';
}
var directory = Directory(text);
if (!directory.existsSync()) {
return "Directory doesn't exist";
}
if (FortniteVersionExtension.findExecutable(directory, "FortniteClient-Win64-Shipping.exe") == null) {
return "Invalid game path";
}
return null;
}
String? checkDownloadDestination(text) {
if (text == null || text.isEmpty) {
return 'Invalid download path';
}
return null;
}
String? checkDll(String? text) {
if (text == null || text.isEmpty) {
return "Empty dll path";
}
if (!File(text).existsSync()) {
return "This dll doesn't exist";
}
if (!text.endsWith(".dll")) {
return "This file is not a dll";
}
return null;
}
String? checkMatchmaking(String? text) {
if (text == null || text.isEmpty) {
return "Empty hostname";
}
var ipParts = text.split(":");
if(ipParts.length > 2){
return "Wrong format, expected ip:port";
}
return null;
}
String? checkUpdateUrl(String? text) {
if (text == null || text.isEmpty) {
return "Empty URL";
}
return null;
}

View File

@@ -0,0 +1,52 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:bcrypt/bcrypt.dart';
import 'package:pointycastle/export.dart';
import 'dart:convert';
const int _ivLength = 16;
const int _keyLength = 32;
String hashPassword(String plaintext) => BCrypt.hashpw(plaintext, BCrypt.gensalt());
bool checkPassword(String password, String hashedText) => BCrypt.checkpw(password, hashedText);
String aes256Encrypt(String plainText, String password) {
final random = Random.secure();
final iv = Uint8List.fromList(List.generate(_ivLength, (index) => random.nextInt(256)));
final keyDerivationData = Uint8List.fromList(utf8.encode(password));
final derive = PBKDF2KeyDerivator(HMac(SHA256Digest(), _ivLength * 8));
var params = Pbkdf2Parameters(iv, _ivLength * 8, _keyLength);
derive.init(params);
final key = derive.process(keyDerivationData);
final cipherParams = PaddedBlockCipherParameters(
KeyParameter(key),
null,
);
final aes = AESEngine();
final paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), aes);
paddingCipher.init(true, cipherParams);
final plainBytes = Uint8List.fromList(utf8.encode(plainText));
final encryptedBytes = paddingCipher.process(plainBytes);
return base64.encode([...iv, ...encryptedBytes]);
}
String aes256Decrypt(String encryptedText, String password) {
final encryptedBytes = base64.decode(encryptedText);
final salt = encryptedBytes.sublist(0, _ivLength);
final payload = encryptedBytes.sublist(_ivLength);
final keyDerivationData = Uint8List.fromList(utf8.encode(password));
final derive = PBKDF2KeyDerivator(HMac(SHA256Digest(), _ivLength * 8));
var params = Pbkdf2Parameters(salt, _ivLength * 8, _keyLength);
derive.init(params);
final key = derive.process(keyDerivationData);
final cipherParams = PaddedBlockCipherParameters(
KeyParameter(key),
null,
);
final aes = AESEngine();
final paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), aes);
paddingCipher.init(false, cipherParams);
final decryptedBytes = paddingCipher.process(payload);
return utf8.decode(decryptedBytes);
}

13
gui/lib/src/util/os.dart Normal file
View File

@@ -0,0 +1,13 @@
import 'dart:io';
final RegExp _winBuildRegex = RegExp(r'(?<=\(Build )(.*)(?=\))');
bool get isWin11 {
var result = _winBuildRegex.firstMatch(Platform.operatingSystemVersion)?.group(1);
if(result == null){
return false;
}
var intBuild = int.tryParse(result);
return intBuild != null && intBuild > 22000;
}

View File

@@ -0,0 +1,17 @@
import 'package:file_picker/file_picker.dart';
Future<String?> openFolderPicker(String title) async =>
await FilePicker.platform.getDirectoryPath(dialogTitle: title);
Future<String?> openFilePicker(String extension) async {
var result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowMultiple: false,
allowedExtensions: [extension]
);
if(result == null || result.files.isEmpty){
return null;
}
return result.files.first.path;
}

View File

@@ -0,0 +1,48 @@
import 'dart:io';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final SupabaseClient _supabase = Supabase.instance.client;
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
extension GameInstanceWatcher on GameInstance {
Future<void> startObserver() async {
if(watchPid != null) {
Process.killPid(watchPid!, ProcessSignal.sigabrt);
}
watchProcess(gamePid).then((value) async {
if(hosting) {
_onHostingStopped();
}
_onGameStopped();
});
watchPid = startBackgroundProcess(
'${assetsDirectory.path}\\misc\\watch.exe',
[_gameController.uuid, gamePid.toString(), launcherPid?.toString() ?? "-1", eacPid?.toString() ?? "-1", hosting.toString()]
);
}
void _onGameStopped() {
_gameController.started.value = false;
_gameController.instance.value?.kill();
if(linkedHosting) {
_onHostingStopped();
}
}
Future<void> _onHostingStopped() async {
_hostingController.started.value = false;
_hostingController.instance.value?.kill();
await _supabase.from('hosts')
.delete()
.match({'id': _gameController.uuid});
}
}

View File

@@ -0,0 +1,75 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:flutter/foundation.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/util/picker.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 => TextFormBox(
controller: widget.controller,
placeholder: widget.placeholder,
validator: widget.validator,
autovalidateMode: widget.validatorMode ?? AutovalidateMode.onUserInteraction,
suffix: !widget.allowNavigator ? null : 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,133 @@
import 'package:auto_animated_list/auto_animated_list.dart';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:skeletons/skeletons.dart';
class SettingTile extends StatefulWidget {
static const double kDefaultContentWidth = 200.0;
static const double kDefaultSpacing = 8.0;
static const double kDefaultHeaderHeight = 72;
final String? title;
final TextStyle? titleStyle;
final String? subtitle;
final TextStyle? subtitleStyle;
final Widget? content;
final double? contentWidth;
final List<Widget>? expandedContent;
final double expandedContentHeaderHeight;
final double expandedContentSpacing;
final bool isChild;
const SettingTile(
{Key? key,
this.title,
this.titleStyle,
this.subtitle,
this.subtitleStyle,
this.content,
this.contentWidth = kDefaultContentWidth,
this.expandedContentHeaderHeight = kDefaultHeaderHeight,
this.expandedContentSpacing = kDefaultSpacing,
this.expandedContent,
this.isChild = false})
: assert(
(title == null && subtitle == null) ||
(title != null && subtitle != null),
"Title and subtitle can only be null together"),
super(key: key);
@override
State<SettingTile> createState() => _SettingTileState();
}
class _SettingTileState extends State<SettingTile> {
@override
Widget build(BuildContext context) {
if (widget.expandedContent == null || widget.expandedContent?.isEmpty == true) {
return _contentCard;
}
return Expander(
initiallyExpanded: true,
headerShape: (open) => const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(4.0)),
),
header: SizedBox(
height: widget.expandedContentHeaderHeight,
child: _buildTile(false)
),
trailing: _trailing,
content: _expandedContent
);
}
Widget get _expandedContent {
var expandedContents = widget.expandedContent!;
var separatedContents = List.generate(expandedContents.length * 2, (index) => index % 2 == 0 ? expandedContents[index ~/ 2] : SizedBox(height: widget.expandedContentSpacing));
return AutoAnimatedList<Widget>(
scrollDirection: Axis.vertical,
shrinkWrap: true,
items: separatedContents,
itemBuilder: (context, child, index, animation) => FadeTransition(
opacity: animation,
child: child
)
);
}
Widget get _trailing =>
SizedBox(width: widget.contentWidth, child: widget.content);
Widget get _contentCard {
if (widget.isChild) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _buildTile(true)
);
}
return Card(
borderRadius: const BorderRadius.vertical(top: Radius.circular(4.0)),
child: _buildTile(true)
);
}
Widget _buildTile(bool trailing) {
return ListTile(
title: widget.title == null ? _skeletonTitle : _title,
subtitle: widget.title == null ? _skeletonSubtitle : _subtitle,
trailing: trailing ? _trailing : null
);
}
Widget get _title => Text(
widget.title!,
style:
widget.titleStyle ?? FluentTheme.of(context).typography.subtitle,
);
Widget get _skeletonTitle => const SkeletonLine(
style: SkeletonLineStyle(
padding: EdgeInsets.only(
right: 24.0
),
height: 18
),
);
Widget get _subtitle => Text(
widget.subtitle!,
style: widget.subtitleStyle ?? FluentTheme.of(context).typography.body
);
Widget get _skeletonSubtitle => const SkeletonLine(
style: SkeletonLineStyle(
padding: EdgeInsets.only(
top: 8.0,
bottom: 8.0,
right: 24.0
),
height: 13
)
);
}

View File

@@ -0,0 +1,420 @@
import 'dart:async';
import 'dart:io';
import 'package:dart_ipify/dart_ipify.dart';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:process_run/shell.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/interactive/game.dart';
import 'package:reboot_launcher/src/interactive/server.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/util/cryptography.dart';
import 'package:reboot_launcher/src/util/watch.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class LaunchButton extends StatefulWidget {
final bool host;
final String? startLabel;
final String? stopLabel;
final bool Function()? onTap;
const LaunchButton({Key? key, required this.host, this.startLabel, this.stopLabel, this.onTap}) : super(key: key);
@override
State<LaunchButton> createState() => _LaunchButtonState();
}
class _LaunchButtonState extends State<LaunchButton> {
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
final SettingsController _settingsController = Get.find<SettingsController>();
final File _logFile = File("${logsDirectory.path}\\game.log");
Completer<bool> _completer = Completer();
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.startLabel ?? (widget.host ? "Start hosting" : "Launch fortnite");
String get _stopMessage => widget.stopLabel ?? (widget.host ? "Stop hosting" : "Close fortnite");
Future<void> _start() async {
if(widget.onTap != null && !widget.onTap!()){
return;
}
if (_hasStarted) {
_onStop(widget.host);
return;
}
if(_gameController.selectedVersion == null){
showMessage("Select a Fortnite version before continuing");
_onStop(widget.host);
return;
}
_setStarted(widget.host, true);
for (var element in Injectable.values) {
if(await _getDllPath(element, widget.host) == null) {
return;
}
}
try {
var version = _gameController.selectedVersion!;
var executable = await version.executable;
if(executable == null){
showMissingBuildError(version);
_onStop(widget.host);
return;
}
var result = _authenticatorController.started() || await _authenticatorController.toggleInteractive(false);
if(!result){
_onStop(widget.host);
return;
}
var automaticallyStartedServer = await _startMatchMakingServer();
await _startGameProcesses(version, widget.host, automaticallyStartedServer);
if(widget.host){
await _showServerLaunchingWarning();
}
} catch (exception, stacktrace) {
_closeLaunchingWidget(false);
_onStop(widget.host);
showCorruptedBuildError(widget.host, exception, stacktrace);
}
}
Future<void> _startGameProcesses(FortniteVersion version, bool host, bool linkedHosting) async {
_setStarted(host, true);
var launcherProcess = await _createLauncherProcess(version);
var eacProcess = await _createEacProcess(version);
var executable = await version.executable;
if(executable == null){
showMissingBuildError(version);
_onStop(widget.host);
return;
}
var gameProcess = await _createGameProcess(executable.path, host);
var instance = GameInstance(gameProcess, launcherProcess, eacProcess, host, linkedHosting);
instance.startObserver();
if(host){
_hostingController.instance.value = instance;
}else{
_gameController.instance.value = instance;
}
_injectOrShowError(Injectable.sslBypass, host);
}
Future<bool> _startMatchMakingServer() async {
if(widget.host){
return false;
}
// var matchmakingIp = _settingsController.matchmakingIp.text;
var matchmakingIp = "127.0.0.1";
if(!isLocalHost(matchmakingIp)) {
return false;
}
if(!_gameController.autoStartGameServer()){
return false;
}
if(_hostingController.started()){
return false;
}
var version = _gameController.selectedVersion!;
await _startGameProcesses(version, true, false);
return true;
}
Future<int> _createGameProcess(String gamePath, bool host) async {
var gameArgs = createRebootArgs(_safeUsername, _gameController.password.text, 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.pid;
}
String get _safeUsername {
if (_gameController.username.text.isEmpty) {
return kDefaultPlayerName;
}
var username = _gameController.username.text;
if(_gameController.password.text.isNotEmpty){
return username;
}
username = _gameController.username.text.replaceAll(RegExp("[^A-Za-z0-9]"), "").trim();
if(username.isEmpty){
return kDefaultPlayerName;
}
return username;
}
Future<int?> _createLauncherProcess(FortniteVersion version) async {
var launcherFile = version.launcher;
if (launcherFile == null) {
return null;
}
var launcherProcess = await Process.start(launcherFile.path, []);
var pid = launcherProcess.pid;
suspend(pid);
return pid;
}
Future<int?> _createEacProcess(FortniteVersion version) async {
var eacFile = version.eacExecutable;
if (eacFile == null) {
return null;
}
var eacProcess = await Process.start(eacFile.path, []);
var pid = eacProcess.pid;
suspend(pid);
return pid;
}
void _onEnd() {
if(_fail){
return;
}
_closeLaunchingWidget(false);
_onStop(widget.host);
}
void _closeLaunchingWidget(bool success) {
showMessage(
success ? "The headless server was started successfully" : "An error occurred while starting the headless server",
severity: success ? InfoBarSeverity.success : InfoBarSeverity.error
);
if(!_completer.isCompleted) {
_completer.complete(success);
}
}
Future<void> _showServerLaunchingWarning() async {
showMessage(
"Launching headless server...",
loading: true,
duration: null
);
var result = await _completer.future;
if(!result){
_onStop(true);
return;
}
if(!_hostingController.discoverable.value){
return;
}
var password = _hostingController.password.text;
var hasPassword = password.isNotEmpty;
var ip = await Ipify.ipv4();
if(hasPassword) {
ip = aes256Encrypt(ip, password);
}
var supabase = Supabase.instance.client;
await supabase.from('hosts').insert({
'id': _gameController.uuid,
'name': _hostingController.name.text,
'description': _hostingController.description.text,
'author': _gameController.username.text,
'ip': ip,
'version': _gameController.selectedVersion?.name,
'password': hasPassword ? hashPassword(password) : null,
'timestamp': DateTime.now().toIso8601String(),
'discoverable': _hostingController.discoverable.value
});
}
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(cannotConnectErrors.any((element) => line.contains(element))){
if(_fail){
return;
}
_fail = true;
_closeLaunchingWidget(false);
_showTokenError(host);
return;
}
if(line.contains("Region ")){
if(!host){
_injectOrShowError(Injectable.console, host);
}else {
_injectOrShowError(Injectable.reboot, host)
.then((value) => _closeLaunchingWidget(true));
}
_injectOrShowError(Injectable.memoryFix, host);
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
instance?.tokenError = false;
}
}
Future<void> _showTokenError(bool host) async {
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
if(_authenticatorController.type() != ServerType.embedded) {
showTokenErrorUnfixable();
instance?.tokenError = true;
return;
}
await _authenticatorController.restartInteractive();
showTokenErrorFixable();
_onStop(host);
_start();
}
void _onStop(bool host) async {
if(_executor != null){
await _executor;
}
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
if(instance != null){
if(instance.linkedHosting){
_onStop(true);
}
instance.kill();
if(host){
_hostingController.instance.value = null;
}else {
_gameController.instance.value = null;
}
}
_setStarted(host, false);
if(host){
var supabase = Supabase.instance.client;
await supabase.from('hosts')
.delete()
.match({'id': _gameController.uuid});
}
_completer = Completer();
}
Future<void> _injectOrShowError(Injectable injectable, bool hosting) async {
var instance = hosting ? _hostingController.instance.value : _gameController.instance.value;
if (instance == null) {
return;
}
try {
var gameProcess = instance.gamePid;
var dllPath = await _getDllPath(injectable, hosting);
if(dllPath == null) {
return;
}
await injectDll(gameProcess, 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((_) {
_fail = true;
_closeLaunchingWidget(false);
showMissingDllError(path.basename(dllPath.path));
_onStop(hosting);
});
}
}
enum Injectable {
console,
sslBypass,
reboot,
memoryFix
}

View File

@@ -0,0 +1,338 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
class RebootPaneItem extends PaneItem {
RebootPaneItem({required super.title, required super.icon, required super.body});
@override
Widget build(
BuildContext context,
bool selected,
VoidCallback? onPressed, {
PaneDisplayMode? displayMode,
bool showTextOnTop = true,
int? itemIndex,
bool? autofocus,
}) {
final maybeBody = _InheritedNavigationView.maybeOf(context);
final mode = displayMode ??
maybeBody?.displayMode ??
maybeBody?.pane?.displayMode ??
PaneDisplayMode.minimal;
assert(mode != PaneDisplayMode.auto);
assert(debugCheckHasFluentTheme(context));
final isTransitioning = maybeBody?.isTransitioning ?? false;
final theme = NavigationPaneTheme.of(context);
final titleText = title?.getProperty<String>() ?? '';
final baseStyle = title?.getProperty<TextStyle>() ?? const TextStyle();
final isTop = mode == PaneDisplayMode.top;
final isMinimal = mode == PaneDisplayMode.minimal;
final isCompact = mode == PaneDisplayMode.compact;
final onItemTapped =
(onPressed == null && onTap == null) || !enabled || isTransitioning
? null
: () {
onPressed?.call();
onTap?.call();
};
final button = HoverButton(
autofocus: autofocus ?? this.autofocus,
focusNode: focusNode,
onPressed: onItemTapped,
cursor: mouseCursor,
focusEnabled: isMinimal ? (maybeBody?.minimalPaneOpen ?? false) : true,
forceEnabled: enabled,
builder: (context, states) {
var textStyle = () {
var style = !isTop
? (selected
? theme.selectedTextStyle?.resolve(states)
: theme.unselectedTextStyle?.resolve(states))
: (selected
? theme.selectedTopTextStyle?.resolve(states)
: theme.unselectedTopTextStyle?.resolve(states));
if (style == null) return baseStyle;
return style.merge(baseStyle);
}();
final textResult = titleText.isNotEmpty
? Padding(
padding: theme.labelPadding ?? EdgeInsets.zero,
child: RichText(
text: title!.getProperty<InlineSpan>(textStyle)!,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
textAlign: title?.getProperty<TextAlign>() ?? TextAlign.start,
textHeightBehavior: title?.getProperty<TextHeightBehavior>(),
textWidthBasis: title?.getProperty<TextWidthBasis>() ??
TextWidthBasis.parent,
),
)
: const SizedBox.shrink();
Widget result() {
final iconThemeData = IconThemeData(
color: textStyle.color ??
(selected
? theme.selectedIconColor?.resolve(states)
: theme.unselectedIconColor?.resolve(states)),
size: textStyle.fontSize ?? 16.0,
);
switch (mode) {
case PaneDisplayMode.compact:
return Container(
key: itemKey,
constraints: const BoxConstraints(
minHeight: kPaneItemMinHeight,
),
alignment: AlignmentDirectional.center,
child: Padding(
padding: theme.iconPadding ?? EdgeInsets.zero,
child: IconTheme.merge(
data: iconThemeData,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: () {
if (infoBadge != null) {
return Stack(
alignment: AlignmentDirectional.center,
clipBehavior: Clip.none,
children: [
icon,
PositionedDirectional(
end: -8,
top: -8,
child: infoBadge!,
),
],
);
}
return icon;
}(),
),
),
),
);
case PaneDisplayMode.minimal:
case PaneDisplayMode.open:
final shouldShowTrailing = !isTransitioning;
return ConstrainedBox(
key: itemKey,
constraints: const BoxConstraints(
minHeight: kPaneItemMinHeight,
),
child: Row(children: [
Padding(
padding: theme.iconPadding ?? EdgeInsets.zero,
child: IconTheme.merge(
data: iconThemeData,
child: Center(child: icon),
),
),
Expanded(child: textResult),
if (shouldShowTrailing) ...[
if (infoBadge != null)
Padding(
padding: const EdgeInsetsDirectional.only(end: 8.0),
child: infoBadge!,
),
if (trailing != null)
IconTheme.merge(
data: const IconThemeData(size: 16.0),
child: trailing!,
),
],
]),
);
case PaneDisplayMode.top:
Widget result = Row(mainAxisSize: MainAxisSize.min, children: [
Padding(
padding: theme.iconPadding ?? EdgeInsets.zero,
child: IconTheme.merge(
data: iconThemeData,
child: Center(child: icon),
),
),
if (showTextOnTop) textResult,
if (trailing != null)
IconTheme.merge(
data: const IconThemeData(size: 16.0),
child: trailing!,
),
]);
if (infoBadge != null) {
return Stack(key: itemKey, clipBehavior: Clip.none, children: [
result,
if (infoBadge != null)
PositionedDirectional(
end: -3,
top: 3,
child: infoBadge!,
),
]);
}
return KeyedSubtree(key: itemKey, child: result);
default:
throw '$mode is not a supported type';
}
}
return Semantics(
label: titleText.isEmpty ? null : titleText,
selected: selected,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 6.0),
decoration: BoxDecoration(
color: () {
final tileColor = this.tileColor ??
theme.tileColor ??
kDefaultPaneItemColor(context, isTop);
final newStates = states.toSet()..remove(ButtonStates.disabled);
if (selected && selectedTileColor != null) {
return selectedTileColor!.resolve(newStates);
}
return tileColor.resolve(
selected
? {
states.isHovering
? ButtonStates.pressing
: ButtonStates.hovering,
}
: newStates,
);
}(),
borderRadius: BorderRadius.circular(4.0),
),
child: FocusBorder(
focused: states.isFocused,
renderOutside: false,
child: () {
final showTooltip = ((isTop && !showTextOnTop) || isCompact) &&
titleText.isNotEmpty &&
!states.isDisabled;
if (showTooltip) {
return Tooltip(
richMessage: title?.getProperty<InlineSpan>(),
style: TooltipThemeData(textStyle: baseStyle),
child: result(),
);
}
return result();
}(),
),
),
);
},
);
final index = () {
if (itemIndex != null) return itemIndex;
if (maybeBody?.pane?.indicator != null) {
return maybeBody!.pane!.effectiveIndexOf(this);
}
}();
return Padding(
key: key,
padding: const EdgeInsetsDirectional.symmetric(horizontal: 12.0, vertical: 2.0),
child: () {
if (maybeBody?.pane?.indicator != null &&
index != null &&
!index.isNegative) {
final key = PaneItemKeys.of(index, context);
return Stack(children: [
button,
Positioned.fill(
child: _InheritedNavigationView.merge(
currentItemIndex: index,
currentItemSelected: selected,
child: KeyedSubtree(
key: key,
child: maybeBody!.pane!.indicator!,
),
),
),
]);
}
return button;
}(),
);
}
}
class _InheritedNavigationView extends InheritedWidget {
const _InheritedNavigationView({
super.key,
required super.child,
required this.displayMode,
this.minimalPaneOpen = false,
this.pane,
this.previousItemIndex = 0,
this.currentItemIndex = -1,
this.isTransitioning = false,
});
final PaneDisplayMode displayMode;
final bool minimalPaneOpen;
final NavigationPane? pane;
final int previousItemIndex;
final int currentItemIndex;
final bool isTransitioning;
static _InheritedNavigationView? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_InheritedNavigationView>();
}
static Widget merge({
Key? key,
required Widget child,
int? currentItemIndex,
NavigationPane? pane,
PaneDisplayMode? displayMode,
bool? minimalPaneOpen,
int? previousItemIndex,
bool? currentItemSelected,
bool? isTransitioning,
}) {
return Builder(builder: (context) {
final current = _InheritedNavigationView.maybeOf(context);
return _InheritedNavigationView(
key: key,
displayMode:
displayMode ?? current?.displayMode ?? PaneDisplayMode.open,
minimalPaneOpen: minimalPaneOpen ?? current?.minimalPaneOpen ?? false,
currentItemIndex: currentItemIndex ?? current?.currentItemIndex ?? -1,
pane: pane ?? current?.pane,
previousItemIndex: previousItemIndex ?? current?.previousItemIndex ?? 0,
isTransitioning: isTransitioning ?? current?.isTransitioning ?? false,
child: child,
);
});
}
@override
bool updateShouldNotify(covariant _InheritedNavigationView oldWidget) {
return oldWidget.displayMode != displayMode ||
oldWidget.minimalPaneOpen != minimalPaneOpen ||
oldWidget.pane != pane ||
oldWidget.previousItemIndex != previousItemIndex ||
oldWidget.currentItemIndex != currentItemIndex ||
oldWidget.isTransitioning != isTransitioning;
}
}

View File

@@ -0,0 +1,101 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/interactive/profile.dart';
class ProfileWidget extends StatefulWidget {
const ProfileWidget({Key? key}) : super(key: key);
@override
State<ProfileWidget> createState() => _ProfileWidgetState();
}
class _ProfileWidgetState extends State<ProfileWidget> {
final GameController _gameController = Get.find<GameController>();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 12.0
),
child: Button(
style: ButtonStyle(
padding: ButtonState.all(EdgeInsets.zero),
backgroundColor: ButtonState.all(Colors.transparent),
border: ButtonState.all(const BorderSide(color: Colors.transparent))
),
onPressed: () async {
if(await showProfileForm(context)) {
setState(() {});
}
},
child: Row(
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
shape: BoxShape.circle
),
child: Image.asset("assets/images/user.png")
),
const SizedBox(
width: 12.0,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_username,
textAlign: TextAlign.start,
style: const TextStyle(
fontWeight: FontWeight.w600
),
maxLines: 1
),
Text(
_email,
textAlign: TextAlign.start,
style: const TextStyle(
fontWeight: FontWeight.w100
),
maxLines: 1
)
],
)
],
),
),
);
String get _username {
var username = _gameController.username.text;
if(username.isEmpty) {
return kDefaultPlayerName;
}
var atIndex = username.indexOf("@");
if(atIndex == -1) {
return username.substring(0, 1).toUpperCase() + username.substring(1);
}
var result = username.substring(0, atIndex);
return result.substring(0, 1).toUpperCase() + result.substring(1);
}
String get _email {
var username = _gameController.username.text;
if(username.isEmpty) {
return "$kDefaultPlayerName@projectreboot.dev";
}
if(username.contains("@")) {
return username.toLowerCase();
}
return "$username@projectreboot.dev".toLowerCase();
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:reboot_common/common.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: const EdgeInsets.only(
top: 1
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: SystemTheme.accentColor.accent,
width: appBarWidth.toDouble()
)
)
),
)
);
}
}

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'icons.dart';
import 'mouse.dart';
typedef WindowButtonIconBuilder = Widget Function(
WindowButtonContext buttonContext);
typedef WindowButtonBuilder = Widget Function(
WindowButtonContext buttonContext, Widget icon);
class WindowButtonContext {
BuildContext context;
MouseState mouseState;
Color? backgroundColor;
Color iconColor;
WindowButtonContext(
{required this.context,
required this.mouseState,
this.backgroundColor,
required this.iconColor});
}
class WindowButtonColors {
late Color normal;
late Color mouseOver;
late Color mouseDown;
late Color iconNormal;
late Color iconMouseOver;
late Color iconMouseDown;
WindowButtonColors(
{Color? normal,
Color? mouseOver,
Color? mouseDown,
Color? iconNormal,
Color? iconMouseOver,
Color? iconMouseDown}) {
this.normal = normal ?? _defaultButtonColors.normal;
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
}
}
final _defaultButtonColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: const Color(0xFF805306),
mouseOver: const Color(0xFF404040),
mouseDown: const Color(0xFF202020),
iconMouseOver: const Color(0xFFFFFFFF),
iconMouseDown: const Color(0xFFF0F0F0));
class WindowButton extends StatelessWidget {
final WindowButtonBuilder? builder;
final WindowButtonIconBuilder? iconBuilder;
late final WindowButtonColors colors;
final bool animate;
final EdgeInsets? padding;
final VoidCallback? onPressed;
WindowButton(
{Key? key,
WindowButtonColors? colors,
this.builder,
@required this.iconBuilder,
this.padding,
this.onPressed,
this.animate = false})
: super(key: key) {
this.colors = colors ?? _defaultButtonColors;
}
Color getBackgroundColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.mouseDown;
if (mouseState.isMouseOver) return colors.mouseOver;
return colors.normal;
}
Color getIconColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.iconMouseDown;
if (mouseState.isMouseOver) return colors.iconMouseOver;
return colors.iconNormal;
}
@override
Widget build(BuildContext context) {
return MouseStateBuilder(
builder: (context, mouseState) {
WindowButtonContext buttonContext = WindowButtonContext(
mouseState: mouseState,
context: context,
backgroundColor: getBackgroundColor(mouseState),
iconColor: getIconColor(mouseState));
var icon =
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
var fadeOutColor =
getBackgroundColor(MouseState()..isMouseOver = true)
.withOpacity(0);
var padding = this.padding ?? EdgeInsets.zero;
var animationMs = mouseState.isMouseOver
? (animate ? 100 : 0)
: (animate ? 200 : 0);
Widget iconWithPadding = Padding(padding: padding, child: icon);
iconWithPadding = AnimatedContainer(
curve: Curves.easeOut,
duration: Duration(milliseconds: animationMs),
color: buttonContext.backgroundColor ?? fadeOutColor,
child: iconWithPadding);
var button = (builder != null)
? builder!(buttonContext, icon)
: iconWithPadding;
return SizedBox.square(dimension: 45, child: button);
},
onPressed: onPressed);
}
}
class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton(
{Key? key,
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super(
key: key,
colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MinimizeIcon(color: buttonContext.iconColor),
onPressed: onPressed ?? () => windowManager.minimize());
}
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
{Key? key,
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super(
key: key,
colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor),
onPressed: onPressed ??
() async => await windowManager.isMaximized()
? await windowManager.restore()
: await windowManager.maximize());
}
class RestoreWindowButton extends WindowButton {
RestoreWindowButton(
{Key? key,
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super(
key: key,
colors: colors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor),
onPressed: onPressed ??
() async => await windowManager.isMaximized()
? await windowManager.restore()
: await windowManager.maximize());
}
final _defaultCloseButtonColors = WindowButtonColors(
mouseOver: const Color(0xFFD32F2F),
mouseDown: const Color(0xFFB71C1C),
iconNormal: const Color(0xFF805306),
iconMouseOver: const Color(0xFFFFFFFF));
class CloseWindowButton extends WindowButton {
CloseWindowButton(
{Key? key,
WindowButtonColors? colors,
VoidCallback? onPressed,
bool? animate})
: super(
key: key,
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor),
onPressed: onPressed ?? () => windowManager.close());
}

View File

@@ -0,0 +1,118 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
class CloseIcon extends StatelessWidget {
final Color color;
const CloseIcon({Key? key, required this.color}) : super(key: key);
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.topLeft,
child: Stack(children: [
Transform.rotate(
angle: pi * .25,
child:
Center(child: Container(width: 14, height: 1, color: color))),
Transform.rotate(
angle: pi * -.25,
child:
Center(child: Container(width: 14, height: 1, color: color))),
]),
);
}
class MaximizeIcon extends StatelessWidget {
final Color color;
const MaximizeIcon({Key? key, required this.color}) : super(key: key);
@override
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
}
class _MaximizePainter extends _IconPainter {
_MaximizePainter(Color color) : super(color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
}
}
class RestoreIcon extends StatelessWidget {
final Color color;
const RestoreIcon({
Key? key,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
}
class _RestorePainter extends _IconPainter {
_RestorePainter(Color color) : super(color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
canvas.drawLine(
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
canvas.drawLine(Offset(size.width, size.height - 2),
Offset(size.width - 2, size.height - 2), p);
}
}
class MinimizeIcon extends StatelessWidget {
final Color color;
const MinimizeIcon({Key? key, required this.color}) : super(key: key);
@override
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
}
class _MinimizePainter extends _IconPainter {
_MinimizePainter(Color color) : super(color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawLine(
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
}
}
abstract class _IconPainter extends CustomPainter {
_IconPainter(this.color);
final Color color;
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _AlignedPaint extends StatelessWidget {
const _AlignedPaint(this.painter, {Key? key}) : super(key: key);
final CustomPainter painter;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: CustomPaint(size: const Size(10, 10), painter: painter));
}
}
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
..color = color
..style = PaintingStyle.stroke
..isAntiAlias = isAntiAlias
..strokeWidth = 1;

View File

@@ -0,0 +1,76 @@
import 'package:flutter/widgets.dart';
typedef MouseStateBuilderCB = Widget Function(
BuildContext context, MouseState mouseState);
class MouseState {
bool isMouseOver = false;
bool isMouseDown = false;
MouseState();
@override
String toString() {
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
}
}
class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder;
final VoidCallback? onPressed;
const MouseStateBuilder({Key? key, required this.builder, this.onPressed})
: super(key: key);
@override
State<MouseStateBuilder> createState() => _MouseStateBuilderState();
}
class _MouseStateBuilderState extends State<MouseStateBuilder> {
late MouseState _mouseState;
_MouseStateBuilderState() {
_mouseState = MouseState();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
_mouseState.isMouseOver = true;
});
},
onExit: (event) {
setState(() {
_mouseState.isMouseOver = false;
});
},
child: GestureDetector(
onTapDown: (_) {
setState(() {
_mouseState.isMouseDown = true;
});
},
onTapCancel: () {
setState(() {
_mouseState.isMouseDown = false;
});
},
onTap: () {
setState(() {
_mouseState.isMouseDown = false;
_mouseState.isMouseOver = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.onPressed != null) {
widget.onPressed!();
}
});
},
onTapUp: (_) {},
child: widget.builder(context, _mouseState)
)
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/widget/os/buttons.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,49 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/controller/server_controller.dart';
import 'package:reboot_launcher/src/interactive/server.dart';
class ServerButton extends StatefulWidget {
final bool authenticator;
const ServerButton({Key? key, required this.authenticator}) : super(key: key);
@override
State<ServerButton> createState() => _ServerButtonState();
}
class _ServerButtonState extends State<ServerButton> {
late final ServerController _controller = widget.authenticator ? Get.find<AuthenticatorController>() : Get.find<MatchmakerController>();
@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: () => _controller.toggleInteractive()
),
)),
),
);
String get _buttonText {
if(_controller.type.value == ServerType.local){
return "Check ${_controller.controllerName}";
}
if(_controller.started.value){
return "Stop ${_controller.controllerName}";
}
return "Start ${_controller.controllerName}";
}
}

View File

@@ -0,0 +1,57 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/src/model/server_type.dart';
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
import 'package:reboot_launcher/src/controller/server_controller.dart';
class ServerTypeSelector extends StatefulWidget {
final bool authenticator;
const ServerTypeSelector({Key? key, required this.authenticator})
: super(key: key);
@override
State<ServerTypeSelector> createState() => _ServerTypeSelectorState();
}
class _ServerTypeSelectorState extends State<ServerTypeSelector> {
late final ServerController _controller = widget.authenticator ? Get.find<AuthenticatorController>() : Get.find<MatchmakerController>();
@override
Widget build(BuildContext context) {
return Obx(() => DropDownButton(
leading: Text(_controller.type.value.label),
items: ServerType.values
.map((type) => _createItem(type))
.toList()
));
}
MenuFlyoutItem _createItem(ServerType type) {
return MenuFlyoutItem(
text: Tooltip(
message: type.message,
child: Text(type.label)
),
onPressed: () async {
await _controller.stop();
_controller.type.value = type;
}
);
}
}
extension ServerTypeExtension on ServerType {
String get label {
return this == ServerType.embedded ? "Embedded"
: this == ServerType.remote ? "Remote"
: "Local";
}
String get message {
return this == ServerType.embedded ? "A server will be automatically started in the background"
: this == ServerType.remote ? "A reverse proxy to the remote server will be created"
: "Assumes that you are running yourself the server locally";
}
}

View File

@@ -0,0 +1,101 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
import 'package:reboot_launcher/src/widget/version/version_name_input.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
import 'package:path/path.dart' as path;
class AddLocalVersion extends StatefulWidget {
const AddLocalVersion({Key? key})
: super(key: key);
@override
State<AddLocalVersion> createState() => _AddLocalVersionState();
}
class _AddLocalVersionState extends State<AddLocalVersion> {
final GameController _gameController = Get.find<GameController>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _gamePathController = TextEditingController();
@override
void initState() {
_gamePathController.addListener(() async {
var file = Directory(_gamePathController.text);
if(await file.exists()) {
if(_nameController.text.isEmpty) {
_nameController.text = path.basename(_gamePathController.text);
}
}
});
super.initState();
}
@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
),
VersionNameInput(
controller: _nameController
),
const SizedBox(
height: 16.0
),
FileSelector(
label: "Game folder",
placeholder: "Type the game folder",
windowTitle: "Select game folder",
controller: _gamePathController,
validator: checkGameFolder,
folder: true
),
const SizedBox(
height: 16.0
)
],
),
buttons: [
DialogButton(
type: ButtonType.secondary
),
DialogButton(
text: "Save",
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop();
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
name: _nameController.text,
location: Directory(_gamePathController.text)
)));
},
)
]
);
}
}

View File

@@ -0,0 +1,342 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/widget/version/version_build_selector.dart';
import 'package:reboot_launcher/src/widget/version/version_name_input.dart';
import 'package:universal_disk_space/universal_disk_space.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/controller/build_controller.dart';
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
import '../../dialog/dialog.dart';
import '../../dialog/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();
final Rx<DownloadStatus> _status = Rx(DownloadStatus.form);
final GlobalKey<FormState> _formKey = GlobalKey();
final RxnInt _timeLeft = RxnInt();
final Rxn<double> _downloadProgress = Rxn();
late DiskSpace _diskSpace;
late Future _fetchFuture;
late Future _diskFuture;
SendPort? _downloadPort;
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());
super.initState();
}
@override
void dispose() {
_pathController.dispose();
_nameController.dispose();
_cancelDownload();
super.dispose();
}
void _cancelDownload() {
Process.run('${assetsDirectory.path}\\misc\\stop.bat', []);
_downloadPort?.send("kill");
}
@override
Widget build(BuildContext context) => Form(
key: _formKey,
child: Obx(() {
switch(_status.value){
case DownloadStatus.form:
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: _formBody,
buttons: _formButtons
);
}
);
case DownloadStatus.downloading:
return GenericDialog(
header: _downloadBody,
buttons: _stopButton
);
case DownloadStatus.extracting:
return GenericDialog(
header: _extractingBody,
buttons: _stopButton
);
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> get _formButtons => [
DialogButton(type: ButtonType.secondary),
DialogButton(
text: "Download",
type: ButtonType.primary,
onTap: () => _startDownload(context),
)
];
void _startDownload(BuildContext context) async {
try {
var build = _buildController.selectedBuild.value;
if(build == null){
return;
}
_status.value = DownloadStatus.downloading;
var communicationPort = ReceivePort();
communicationPort.listen((message) {
if(message is ArchiveDownloadProgress) {
_onDownloadProgress(message.progress, message.minutesLeft, message.extracting);
}else if(message is SendPort) {
_downloadPort = message;
}else {
_onDownloadError("Unexpected message: $message", null);
}
});
var options = ArchiveDownloadOptions(
build.link,
Directory(_pathController.text),
communicationPort.sendPort
);
var errorPort = ReceivePort();
errorPort.listen((message) => _onDownloadError(message, null));
var exitPort = ReceivePort();
exitPort.listen((message) {
if(_status.value != DownloadStatus.error) {
_onDownloadComplete();
}
});
await Isolate.spawn(
downloadArchiveBuild,
options,
onError: errorPort.sendPort,
onExit: exitPort.sendPort,
errorsAreFatal: true
);
} catch (exception, stackTrace) {
_onDownloadError(exception, stackTrace);
}
}
Future<void> _onDownloadComplete() async {
if (!mounted) {
return;
}
_status.value = DownloadStatus.done;
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
name: _nameController.text,
location: Directory(_pathController.text)
)));
}
void _onDownloadError(Object? error, StackTrace? stackTrace) {
if (!mounted) {
return;
}
_status.value = DownloadStatus.error;
_error = error;
_stackTrace = stackTrace;
}
void _onDownloadProgress(double progress, int timeLeft, bool extracting) {
if (!mounted) {
return;
}
_status.value = extracting ? DownloadStatus.extracting : DownloadStatus.downloading;
_timeLeft.value = timeLeft;
_downloadProgress.value = progress;
}
Widget get _downloadBody {
var timeLeft = _timeLeft.value;
return 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.value ?? 0).round()}%",
style: FluentTheme.maybeOf(context)?.typography.body,
),
if(timeLeft != null)
Text(
"Time left: ${timeLeft == 0 ? "less than a minute" : "about $timeLeft minute${timeLeft > 1 ? 's' : ''}"}",
style: FluentTheme.maybeOf(context)?.typography.body,
)
],
),
const SizedBox(
height: 8.0,
),
SizedBox(
width: double.infinity,
child: ProgressBar(value: (_downloadProgress.value ?? 0).toDouble())
),
const SizedBox(
height: 8.0,
)
],
);
}
Widget get _extractingBody => 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 get _formBody => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BuildSelector(
onSelected: _updateFormDefaults
),
const SizedBox(
height: 16.0
),
VersionNameInput(
controller: _nameController
),
const SizedBox(
height: 16.0
),
FileSelector(
label: "Installation directory",
placeholder: "Type the installation directory",
windowTitle: "Select installation directory",
controller: _pathController,
validator: checkDownloadDestination,
folder: true
),
const SizedBox(
height: 16.0
)
],
);
List<DialogButton> get _stopButton => [
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);
var build = _buildController.selectedBuild.value;
if(build== null){
return;
}
_pathController.text = "${bestDisk.devicePath}\\FortniteBuilds\\${build.version}";
_nameController.text = build.version.toString();
_formKey.currentState?.validate();
}
}
enum DownloadStatus { form, downloading, extracting, error, done }

View File

@@ -0,0 +1,51 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/build_controller.dart';
class BuildSelector extends StatefulWidget {
final Function() onSelected;
const BuildSelector({Key? key, required this.onSelected}) : 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: Obx(() => ComboBox<FortniteBuild>(
placeholder: const Text('Select a fortnite build'),
isExpanded: true,
items: _createItems(),
value: _buildController.selectedBuild.value,
onChanged: (value) {
if(value == null){
return;
}
_buildController.selectedBuild.value = value;
widget.onSelected();
}
))
);
}
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,34 @@
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/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) => InfoLabel(
label: "Name",
child: TextFormBox(
controller: controller,
placeholder: "Type the version's name",
autofocus: true,
validator: _validate,
autovalidateMode: AutovalidateMode.onUserInteraction
),
);
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,258 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
import 'package:flutter/gestures.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/widget/version/add_local_version.dart';
import 'package:reboot_launcher/src/widget/version/add_server_version.dart';
import 'package:reboot_launcher/src/dialog/dialog.dart';
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
import 'package:reboot_launcher/src/dialog/message.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
class VersionSelector extends StatefulWidget {
const VersionSelector({Key? key}) : super(key: key);
static Future<void> openDownloadDialog() => showDialog<bool>(
builder: (context) => const AddServerVersion(),
);
static Future<void> openAddDialog() => showDialog<bool>(
builder: (context) => const AddLocalVersion(),
);
@override
State<VersionSelector> createState() => _VersionSelectorState();
}
class _VersionSelectorState extends State<VersionSelector> {
final GameController _gameController = Get.find<GameController>();
final RxBool _deleteFilesController = RxBool(false);
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 (_deleteFilesController.value && await version.location.exists()) {
delete(version.location);
}
break;
default:
break;
}
}
MenuFlyoutItem _createOption(BuildContext context, ContextualOption entry) {
return MenuFlyoutItem(
text: Text(entry.name),
onPressed: () => Navigator.of(context).pop(entry)
);
}
bool _onExplorerError() {
showMessage("This version doesn't exist on the local machine");
return false;
}
Future<bool?> _openDeleteDialog(BuildContext context, FortniteVersion version) {
return showDialog<bool>(
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),
Obx(() => Checkbox(
checked: _deleteFilesController.value,
onChanged: (bool? value) => _deleteFilesController.value = value ?? false,
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?>(
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: "Name",
child: TextFormBox(
controller: nameController,
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",
label: "Path",
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";
}
}