mirror of
https://github.com/Auties00/Reboot-Launcher.git
synced 2026-01-13 11:12:23 +01:00
Latest version
This commit is contained in:
@@ -19,11 +19,7 @@ late String dll;
|
||||
late FortniteVersion version;
|
||||
late bool autoRestart;
|
||||
|
||||
void main(List<String> args){
|
||||
handleCLI(args);
|
||||
}
|
||||
|
||||
Future<void> handleCLI(List<String> args) async {
|
||||
void main(List<String> args) async {
|
||||
stdout.writeln("Reboot Launcher");
|
||||
stdout.writeln("Wrote by Auties00");
|
||||
stdout.writeln("Version 5.3");
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:bitsdojo_window_windows/bitsdojo_window_windows.dart'
|
||||
show WinDesktopWindow;
|
||||
import 'package:dart_vlc/dart_vlc.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
@@ -21,17 +20,10 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final GlobalKey appKey = GlobalKey();
|
||||
|
||||
void main(List<String> args) async {
|
||||
void main() async {
|
||||
await Directory(safeBinariesDirectory)
|
||||
.create(recursive: true);
|
||||
if(args.isNotEmpty){
|
||||
handleCLI(args);
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartVLC.initialize();
|
||||
|
||||
await SystemTheme.accentColor.load();
|
||||
await GetStorage.init("game");
|
||||
await GetStorage.init("server");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -6,6 +7,7 @@ import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/model/game_instance.dart';
|
||||
import 'package:reboot_launcher/src/model/game_type.dart';
|
||||
|
||||
class GameController extends GetxController {
|
||||
@@ -15,11 +17,10 @@ class GameController extends GetxController {
|
||||
late final Rx<List<FortniteVersion>> versions;
|
||||
late final Rxn<FortniteVersion> _selectedVersion;
|
||||
late final Rx<GameType> type;
|
||||
late final HashMap<GameType, GameInstance> gameInstancesMap;
|
||||
late final RxBool started;
|
||||
late bool updated;
|
||||
Future? updater;
|
||||
Process? gameProcess;
|
||||
Process? launcherProcess;
|
||||
Process? eacProcess;
|
||||
|
||||
GameController() {
|
||||
_storage = GetStorage("game");
|
||||
@@ -40,19 +41,22 @@ class GameController extends GetxController {
|
||||
type = Rx(GameType.values.elementAt(_storage.read("type") ?? 0));
|
||||
type.listen((value) {
|
||||
_storage.write("type", value.index);
|
||||
username.text = _storage.read("${type.value == GameType.client ? 'game' : 'host'}_username") ?? "";
|
||||
username.text = _readUsername();
|
||||
});
|
||||
|
||||
username = TextEditingController(text: _storage.read("${type.value == GameType.client ? 'game' : 'host'}_username") ?? "");
|
||||
username = TextEditingController(text: _readUsername());
|
||||
username.addListener(() => _storage.write("${type.value == GameType.client ? 'game' : 'host'}_username", username.text));
|
||||
|
||||
gameInstancesMap= HashMap();
|
||||
|
||||
started = RxBool(false);
|
||||
|
||||
updated = false;
|
||||
}
|
||||
|
||||
void kill() {
|
||||
gameProcess?.kill(ProcessSignal.sigabrt);
|
||||
launcherProcess?.kill(ProcessSignal.sigabrt);
|
||||
eacProcess?.kill(ProcessSignal.sigabrt);
|
||||
String _readUsername() {
|
||||
var client = type.value == GameType.client;
|
||||
return _storage.read("${client ? 'game' : 'host'}_username") ?? (client ? "" : "HostingServer");
|
||||
}
|
||||
|
||||
FortniteVersion? getVersionByName(String name) {
|
||||
@@ -86,6 +90,8 @@ class GameController extends GetxController {
|
||||
|
||||
Rxn<FortniteVersion> get selectedVersionObs => _selectedVersion;
|
||||
|
||||
GameInstance? get currentGameInstance => gameInstancesMap[type()];
|
||||
|
||||
set selectedVersion(FortniteVersion? version) {
|
||||
_selectedVersion(version);
|
||||
_storage.write("version", version?.name);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dart_vlc/dart_vlc.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/src/model/tutorial_page.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
@@ -13,11 +13,13 @@ class SettingsController extends GetxController {
|
||||
late final TextEditingController authDll;
|
||||
late final TextEditingController matchmakingIp;
|
||||
late final Rx<PaneDisplayMode> displayType;
|
||||
late final RxBool doNotAskAgain;
|
||||
late Rx<TutorialPage> tutorialPage;
|
||||
late double width;
|
||||
late double height;
|
||||
late double? offsetX;
|
||||
late double? offsetY;
|
||||
Player? player;
|
||||
late double scrollingDistance;
|
||||
|
||||
SettingsController() {
|
||||
_storage = GetStorage("settings");
|
||||
@@ -31,11 +33,18 @@ class SettingsController extends GetxController {
|
||||
_storage.write("ip", text);
|
||||
});
|
||||
|
||||
doNotAskAgain = RxBool(_storage.read("do_not_ask_again") ?? false);
|
||||
doNotAskAgain.listen((value) => _storage.write("do_not_ask_again", value));
|
||||
|
||||
width = _storage.read("width") ?? window.physicalSize.width;
|
||||
height = _storage.read("height") ?? window.physicalSize.height;
|
||||
offsetX = _storage.read("offset_x");
|
||||
offsetY = _storage.read("offset_y");
|
||||
displayType = Rx(PaneDisplayMode.top);
|
||||
|
||||
scrollingDistance = 0.0;
|
||||
|
||||
tutorialPage = Rx(TutorialPage.start);
|
||||
}
|
||||
|
||||
TextEditingController _createController(String key, String name) {
|
||||
|
||||
@@ -29,7 +29,7 @@ class GenericDialog extends AbstractDialog {
|
||||
),
|
||||
|
||||
ContentDialog(
|
||||
style: ContentDialogThemeData(
|
||||
style: ContentDialogThemeData(
|
||||
padding: padding ?? const EdgeInsets.only(left: 20, right: 20, top: 15.0, bottom: 5.0)
|
||||
),
|
||||
content: header,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
@@ -159,7 +159,7 @@ extension ServerControllerDialog on ServerController {
|
||||
builder: (context) =>
|
||||
FutureBuilderDialog(
|
||||
future: Future.wait([
|
||||
pingSelf(port.text),
|
||||
compute(pingSelf, port.text),
|
||||
Future.delayed(const Duration(seconds: 1))
|
||||
]),
|
||||
loadingMessage: "Pinging ${type().id} server...",
|
||||
|
||||
@@ -105,7 +105,7 @@ Jaguar _createServer(String Function() ipQuery) {
|
||||
server.getJson("/fortnite/api/game/v2/privacy/account/:accountId", getPrivacy);
|
||||
server.postJson("/fortnite/api/game/v2/privacy/account/:accountId", postPrivacy);
|
||||
|
||||
return _addLoggingCapabilities(server);
|
||||
return server;
|
||||
}
|
||||
Jaguar _createMatchmaker(){
|
||||
var server = Jaguar(address: "127.0.0.1", port: 8080);
|
||||
|
||||
15
lib/src/model/game_instance.dart
Normal file
15
lib/src/model/game_instance.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
class GameInstance {
|
||||
final Process gameProcess;
|
||||
final Process? launcherProcess;
|
||||
final Process? eacProcess;
|
||||
|
||||
GameInstance(this.gameProcess, this.launcherProcess, this.eacProcess);
|
||||
|
||||
void kill() {
|
||||
gameProcess.kill(ProcessSignal.sigabrt);
|
||||
launcherProcess?.kill(ProcessSignal.sigabrt);
|
||||
eacProcess?.kill(ProcessSignal.sigabrt);
|
||||
}
|
||||
}
|
||||
5
lib/src/model/tutorial_page.dart
Normal file
5
lib/src/model/tutorial_page.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
enum TutorialPage {
|
||||
start,
|
||||
someoneElse,
|
||||
yourOwn
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart' hide WindowBorder;
|
||||
@@ -18,6 +17,7 @@ import 'package:reboot_launcher/src/widget/os/window_buttons.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controller/settings_controller.dart';
|
||||
import '../model/tutorial_page.dart';
|
||||
import 'info_page.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
@@ -31,7 +31,6 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
static const double _headerSize = 48.0;
|
||||
static const double _sectionSize = 100.0;
|
||||
static const double _defaultPadding = 12.0;
|
||||
static const double _openMenuSize = 320.0;
|
||||
static const int _headerButtonCount = 3;
|
||||
static const int _sectionButtonCount = 4;
|
||||
|
||||
@@ -45,6 +44,7 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
final Rxn<List<NavigationPaneItem>> _searchItems = Rxn();
|
||||
final RxBool _focused = RxBool(true);
|
||||
final RxInt _index = RxInt(0);
|
||||
bool _navigated = false;
|
||||
|
||||
bool _shouldMaximize = false;
|
||||
|
||||
@@ -125,7 +125,11 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
child: Obx(() => Stack(
|
||||
children: [
|
||||
_createNavigationView(),
|
||||
_createTitleBar(),
|
||||
if(_settingsController.displayType() == PaneDisplayMode.top)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: WindowTitleBar(focused: _focused())
|
||||
),
|
||||
if(_settingsController.displayType() == PaneDisplayMode.top)
|
||||
_createTopDisplayGestures(),
|
||||
if(_focused() && isWin11)
|
||||
@@ -161,49 +165,82 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
child: child
|
||||
);
|
||||
|
||||
NavigationView _createNavigationView() => NavigationView(
|
||||
paneBodyBuilder: (body) => _createPage(body),
|
||||
pane: NavigationPane(
|
||||
size: const NavigationPaneSize(
|
||||
topHeight: _headerSize
|
||||
NavigationView _createNavigationView() {
|
||||
return NavigationView(
|
||||
paneBodyBuilder: (body) => _createPage(body),
|
||||
pane: NavigationPane(
|
||||
size: const NavigationPaneSize(
|
||||
topHeight: _headerSize
|
||||
),
|
||||
selected: _selectedIndex,
|
||||
onChanged: _onIndexChanged,
|
||||
displayMode: _settingsController.displayType(),
|
||||
items: _createItems(),
|
||||
indicator: const EndNavigationIndicator(),
|
||||
footerItems: _createFooterItems(),
|
||||
header: _settingsController.displayType() != PaneDisplayMode.open ? null : const SizedBox(height: _defaultPadding),
|
||||
autoSuggestBox: _createAutoSuggestBox(),
|
||||
autoSuggestBoxReplacement: _settingsController.displayType() == PaneDisplayMode.top ? null : const Icon(FluentIcons.search),
|
||||
),
|
||||
selected: _selectedIndex,
|
||||
onChanged: (index) {
|
||||
_settingsController.player?.pause();
|
||||
_index.value = index;
|
||||
},
|
||||
displayMode: _settingsController.displayType(),
|
||||
indicator: const EndNavigationIndicator(),
|
||||
items: _createItems(),
|
||||
footerItems: _createFooterItems(),
|
||||
header: _settingsController.displayType() != PaneDisplayMode.open ? null : const SizedBox(height: _defaultPadding),
|
||||
autoSuggestBox: _settingsController.displayType() == PaneDisplayMode.top ? null : TextBox(
|
||||
key: _searchKey,
|
||||
controller: _searchController,
|
||||
placeholder: 'Search',
|
||||
focusNode: _searchFocusNode
|
||||
),
|
||||
autoSuggestBoxReplacement: _settingsController.displayType() == PaneDisplayMode.top ? null : const Icon(FluentIcons.search),
|
||||
),
|
||||
onOpenSearch: () => _searchFocusNode.requestFocus(),
|
||||
transitionBuilder: _settingsController.displayType() == PaneDisplayMode.top ? null : (child, animation) => child
|
||||
);
|
||||
onOpenSearch: () => _searchFocusNode.requestFocus(),
|
||||
transitionBuilder: _settingsController.displayType() == PaneDisplayMode.top ? null : (child, animation) => child
|
||||
);
|
||||
}
|
||||
|
||||
RenderObjectWidget _createPage(Widget? body) => Padding(
|
||||
padding: _createPagePadding(),
|
||||
child: body
|
||||
);
|
||||
void _onIndexChanged(int index) {
|
||||
_index.value = index;
|
||||
_navigated = true;
|
||||
}
|
||||
|
||||
EdgeInsets _createPagePadding() {
|
||||
TextBox? _createAutoSuggestBox() {
|
||||
if (_settingsController.displayType() == PaneDisplayMode.top) {
|
||||
return const EdgeInsets.all(_defaultPadding);
|
||||
return null;
|
||||
}
|
||||
|
||||
return const EdgeInsets.only(
|
||||
top: 32,
|
||||
left: _defaultPadding,
|
||||
right: _defaultPadding,
|
||||
bottom: _defaultPadding
|
||||
return TextBox(
|
||||
key: _searchKey,
|
||||
controller: _searchController,
|
||||
placeholder: 'Search',
|
||||
focusNode: _searchFocusNode
|
||||
);
|
||||
}
|
||||
|
||||
RenderObjectWidget _createPage(Widget? body) {
|
||||
if(_settingsController.displayType() == PaneDisplayMode.top){
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
child: body
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _createWindowGestures(
|
||||
child: Container(
|
||||
height: _headerSize,
|
||||
color: Colors.transparent
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
WindowTitleBar(focused: _focused())
|
||||
],
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: _defaultPadding,
|
||||
right: _defaultPadding,
|
||||
bottom: _defaultPadding
|
||||
),
|
||||
child: body
|
||||
)
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +269,8 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
PaneItem(
|
||||
title: const Text("Tutorial"),
|
||||
icon: const Icon(FluentIcons.info),
|
||||
body: const InfoPage()
|
||||
body: const InfoPage(),
|
||||
onTap: _onTutorial
|
||||
)
|
||||
];
|
||||
|
||||
@@ -259,10 +297,22 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
PaneItem(
|
||||
title: const Text("Tutorial"),
|
||||
icon: const Icon(FluentIcons.info),
|
||||
body: const InfoPage()
|
||||
body: const InfoPage(),
|
||||
onTap: _onTutorial
|
||||
)
|
||||
];
|
||||
|
||||
void _onTutorial() {
|
||||
if(!_navigated){
|
||||
setState(() {
|
||||
_settingsController.tutorialPage.value = TutorialPage.start;
|
||||
_settingsController.scrollingDistance = 0;
|
||||
});
|
||||
}
|
||||
|
||||
_navigated = false;
|
||||
}
|
||||
|
||||
bool _calculateSize() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_settingsController.saveWindowSize();
|
||||
@@ -288,35 +338,5 @@ class _HomePageState extends State<HomePage> with WindowListener {
|
||||
return true;
|
||||
}
|
||||
|
||||
Widget _createTitleBar() => Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: _createTitleBarContent(),
|
||||
);
|
||||
|
||||
Widget _createTitleBarContent() {
|
||||
if(_settingsController.displayType() == PaneDisplayMode.top) {
|
||||
return WindowTitleBar(focused: _focused());
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _settingsController.displayType() == PaneDisplayMode.open ? _openMenuSize : _headerSize,
|
||||
height: _headerSize
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: _createWindowGestures(
|
||||
child: Container(
|
||||
height: _headerSize,
|
||||
color: Colors.transparent
|
||||
)
|
||||
)
|
||||
),
|
||||
WindowTitleBar(focused: _focused())
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String get searchValue => _searchController.text;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
|
||||
import 'package:dart_vlc/dart_vlc.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide Card;
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../controller/settings_controller.dart';
|
||||
import '../model/tutorial_page.dart';
|
||||
|
||||
class InfoPage extends StatefulWidget {
|
||||
const InfoPage({Key? key}) : super(key: key);
|
||||
@@ -15,36 +13,149 @@ class InfoPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _InfoPageState extends State<InfoPage> {
|
||||
final List<String> _elseTitles = [
|
||||
"Open the settings tab",
|
||||
"Type the ip address of the host, including the port if it's not 7777\n The complete address should follow the schema ip:port",
|
||||
"Open the home page",
|
||||
"Type your username if you haven't already",
|
||||
"Select the exact version that the host is using from the dropdown menu\n If necessary, install it using the download button",
|
||||
"As you want to play, select client from the dropdown menu",
|
||||
"Click launch to open the game",
|
||||
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
|
||||
];
|
||||
final List<String> _ownTitles = [
|
||||
"Open the settings tab",
|
||||
"Type 127.0.0.1 as the matchmaking host",
|
||||
"Open the home page",
|
||||
"Type your username if you haven't already",
|
||||
"Select the version you want to host\n If necessary, install it using the download button",
|
||||
"As you want to host, select Headless Server from the dropdown menu\n If the headless server doesn't work for your version, use the normal server instead",
|
||||
"Click launch to start the server and wait until the Reboot GUI shows up",
|
||||
"To allow your friends to join your server, follow the instructions on playit.gg\n If you are an advanced user, open port 7777 on your router\n Finally, share your playit ip or public IPv4 address with your friends\n If you just want to play by yourself, skip this step",
|
||||
"When you want to start the game, click on the 'Start Bus Countdown' button",
|
||||
"If you also want to play, start a client by selecting Client from the dropdown menu\n Don't close or open again the launcher, use the same window",
|
||||
"Click launch to open the game",
|
||||
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
|
||||
];
|
||||
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
late final ScrollController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if(_settingsController.player == null){
|
||||
var player = Player(id: 1);
|
||||
player.open(
|
||||
Media.network("https://cdn.discordapp.com/attachments/1006260074416701450/1038844107986055190/tutorial.mp4")
|
||||
);
|
||||
_settingsController.player = player;
|
||||
}
|
||||
|
||||
_settingsController.player?.play();
|
||||
_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) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Card(
|
||||
child: Video(
|
||||
player: _settingsController.player,
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
width: MediaQuery.of(context).size.width * 0.90,
|
||||
scale: 1.0,
|
||||
showControls: true,
|
||||
switch(_settingsController.tutorialPage()) {
|
||||
case TutorialPage.start:
|
||||
return _createHomeScreen();
|
||||
case TutorialPage.someoneElse:
|
||||
return _createInstructions(false);
|
||||
case TutorialPage.yourOwn:
|
||||
return _createInstructions(true);
|
||||
}
|
||||
}
|
||||
|
||||
SizedBox _createInstructions(bool own) {
|
||||
var titles = own ? _ownTitles : _elseTitles;
|
||||
var codeName = own ? "own" : "else";
|
||||
return SizedBox.expand(
|
||||
child: ListView.separated(
|
||||
controller: _controller,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 20.0
|
||||
),
|
||||
child: Card(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
|
||||
child: ListTile(
|
||||
title: SelectableText("${index + 1}. ${titles[index]}"),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Image.asset("assets/images/tutorial_${codeName}_${index + 1}.png"),
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8.0),
|
||||
itemCount: titles.length,
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createHomeScreen() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_createCardWidget(
|
||||
text: "Play on someone else's server",
|
||||
description: "If one of your friends is hosting a game server, click here",
|
||||
onClick: () => setState(() => _settingsController.tutorialPage.value = TutorialPage.someoneElse)
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
|
||||
_createCardWidget(
|
||||
text: "Host your own server",
|
||||
description: "If you want to create your own server to invite your friends or to play around by yourself, click here",
|
||||
onClick: () => setState(() => _settingsController.tutorialPage.value = TutorialPage.yourOwn)
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createCardWidget({required String text, required String description, required Function() onClick}) {
|
||||
return Expanded(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onClick,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 20.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -30,7 +32,8 @@ class _LauncherPageState extends State<LauncherPage> {
|
||||
void initState() {
|
||||
if(_gameController.updater == null){
|
||||
_gameController.updater = compute(downloadRebootDll, _updateTime)
|
||||
..then((value) => _updateTime = value);
|
||||
..then((value) => _updateTime = value)
|
||||
..then((value) => _gameController.updated = true);
|
||||
_buildController.cancelledDownload
|
||||
.listen((value) => value ? _onCancelWarning() : {});
|
||||
}
|
||||
@@ -65,7 +68,7 @@ class _LauncherPageState extends State<LauncherPage> {
|
||||
return FutureBuilder(
|
||||
future: _gameController.updater ?? Future.value(true),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData && !snapshot.hasError) {
|
||||
if (!_gameController.updated && !snapshot.hasData && !snapshot.hasError) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/snackbar.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/widget/shared/smart_switch.dart';
|
||||
|
||||
@@ -24,17 +25,16 @@ class SettingsPage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: "The hostname of the server that hosts the multiplayer matches",
|
||||
child: Obx(() => SmartInput(
|
||||
label: "Matchmaking Host",
|
||||
placeholder:
|
||||
"Type the hostname of the server that hosts the multiplayer matches",
|
||||
controller: _settingsController.matchmakingIp,
|
||||
validatorMode: AutovalidateMode.always,
|
||||
validator: checkMatchmaking,
|
||||
enabled: _serverController.type() == ServerType.embedded
|
||||
))
|
||||
),
|
||||
message:
|
||||
"The hostname of the server that hosts the multiplayer matches",
|
||||
child: Obx(() => SmartInput(
|
||||
label: "Matchmaking Host",
|
||||
placeholder:
|
||||
"Type the hostname of the server that hosts the multiplayer matches",
|
||||
controller: _settingsController.matchmakingIp,
|
||||
validatorMode: AutovalidateMode.always,
|
||||
validator: checkMatchmaking,
|
||||
enabled: _serverController.type() == ServerType.embedded))),
|
||||
Tooltip(
|
||||
message: "The dll that is injected when a server is launched",
|
||||
child: FileSelector(
|
||||
@@ -63,13 +63,25 @@ class SettingsPage extends StatelessWidget {
|
||||
message: "The dll that is injected to make the game work",
|
||||
child: FileSelector(
|
||||
label: "Cranium DLL",
|
||||
placeholder: "Type the path to the dll used for authentication",
|
||||
placeholder:
|
||||
"Type the path to the dll used for authentication",
|
||||
controller: _settingsController.authDll,
|
||||
windowTitle: "Select a dll",
|
||||
folder: false,
|
||||
extension: "dll",
|
||||
validator: checkDll,
|
||||
validatorMode: AutovalidateMode.always))
|
||||
validatorMode: AutovalidateMode.always)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Version Status"),
|
||||
const SizedBox(height: 6.0),
|
||||
Button(
|
||||
child: const Text("6.0${kDebugMode ? '-DEBUG' : '-RELEASE'}"),
|
||||
onPressed: () => showMessage("What a nice launcher")
|
||||
)
|
||||
],
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
extension FutureExtension<T> on Future<T> {
|
||||
bool isCompleted() {
|
||||
final completer = Completer<T>();
|
||||
then(completer.complete).catchError(completer.completeError);
|
||||
return completer.isCompleted;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ class GameTypeSelector extends StatelessWidget {
|
||||
child: Text(type.name)
|
||||
)
|
||||
),
|
||||
onPressed: () => _gameController.type(type)
|
||||
onPressed: () {
|
||||
_gameController.type(type);
|
||||
_gameController.started.value = _gameController.currentGameInstance != null;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -11,6 +10,7 @@ import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/game_dialogs.dart';
|
||||
import 'package:reboot_launcher/src/dialog/server_dialogs.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/model/game_type.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
@@ -21,9 +21,12 @@ import 'package:reboot_launcher/src/util/server.dart';
|
||||
import 'package:win32_suspend_process/win32_suspend_process.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../controller/settings_controller.dart';
|
||||
import '../../dialog/snackbar.dart';
|
||||
import 'package:reboot_launcher/src/../main.dart';
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/snackbar.dart';
|
||||
import 'package:reboot_launcher/src/model/game_instance.dart';
|
||||
|
||||
import '../shared/smart_check_box.dart';
|
||||
|
||||
class LaunchButton extends StatefulWidget {
|
||||
const LaunchButton(
|
||||
@@ -35,6 +38,7 @@ class LaunchButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LaunchButtonState extends State<LaunchButton> {
|
||||
final String _shutdownLine = "FOnlineSubsystemGoogleCommon::Shutdown()";
|
||||
final List<String> _errorStrings = [
|
||||
"port 3551 failed: Connection refused",
|
||||
"Unable to login to Fortnite servers",
|
||||
@@ -43,7 +47,6 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
"UOnlineAccountCommon::ForceLogout"
|
||||
];
|
||||
|
||||
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final ServerController _serverController = Get.find<ServerController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
@@ -76,83 +79,180 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
|
||||
void _onPressed() async {
|
||||
if (_gameController.started()) {
|
||||
_onStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_gameController.username.text.isEmpty && _gameController.type() != GameType.client) {
|
||||
showMessage("Missing username");
|
||||
_gameController.started.value = false;
|
||||
_onStop(_gameController.type());
|
||||
return;
|
||||
}
|
||||
|
||||
_gameController.started.value = true;
|
||||
if (_gameController.selectedVersionObs.value == null) {
|
||||
showMessage("No version is selected");
|
||||
_gameController.started.value = false;
|
||||
return;
|
||||
if (_gameController.username.text.isEmpty) {
|
||||
if(_serverController.type() != ServerType.local){
|
||||
showMessage("Missing username");
|
||||
_onStop(_gameController.type());
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("No username: expecting self sign in");
|
||||
}
|
||||
|
||||
if (_gameController.selectedVersionObs.value == null) {
|
||||
showMessage("No version is selected");
|
||||
_onStop(_gameController.type());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _resetLogFile();
|
||||
|
||||
var version = _gameController.selectedVersionObs.value!;
|
||||
var gamePath = version.executable?.path;
|
||||
if(gamePath == null){
|
||||
_onError("${version.location.path} no longer contains a Fortnite executable, did you delete or move it?", null);
|
||||
_onStop();
|
||||
_onError("${version.location.path} no longer contains a Fortnite executable, did you delete it?", null);
|
||||
_onStop(_gameController.type());
|
||||
return;
|
||||
}
|
||||
|
||||
if (version.launcher != null) {
|
||||
_gameController.launcherProcess = await Process.start(version.launcher!.path, []);
|
||||
Win32Process(_gameController.launcherProcess!.pid).suspend();
|
||||
}
|
||||
|
||||
if (version.eacExecutable != null) {
|
||||
_gameController.eacProcess = await Process.start(version.eacExecutable!.path, []);
|
||||
Win32Process(_gameController.eacProcess!.pid).suspend();
|
||||
}
|
||||
|
||||
var result = await _serverController.start(
|
||||
required: true,
|
||||
askPortKill: false,
|
||||
);
|
||||
|
||||
var result = await _serverController.start(required: true, askPortKill: false);
|
||||
if(!result){
|
||||
showMessage("Cannot launch the game as the backend didn't start up correctly");
|
||||
_onStop();
|
||||
_onStop(_gameController.type());
|
||||
return;
|
||||
}
|
||||
|
||||
if(_logFile != null && await _logFile!.exists()){
|
||||
await _logFile!.delete();
|
||||
}
|
||||
|
||||
await compute(patchMatchmaking, version.executable!);
|
||||
await compute(patchHeadless, version.executable!);
|
||||
|
||||
var headlessHosting = _gameController.type() == GameType.headlessServer;
|
||||
var arguments = createRebootArgs(_gameController.username.text, _gameController.type.value);
|
||||
_gameController.gameProcess = await Process.start(gamePath, arguments)
|
||||
..exitCode.then((_) => _onEnd())
|
||||
..outLines.forEach((line) => _onGameOutput(line))
|
||||
..errLines.forEach((line) => _onGameOutput(line));
|
||||
_injectOrShowError(Injectable.cranium);
|
||||
if(headlessHosting){
|
||||
await _startMatchMakingServer();
|
||||
await _startGameProcesses(version, _gameController.type());
|
||||
|
||||
if(_gameController.type() == GameType.headlessServer){
|
||||
await _showServerLaunchingWarning();
|
||||
}
|
||||
} catch (exception, stacktrace) {
|
||||
_closeDialogIfOpen(false);
|
||||
_onError(exception, stacktrace);
|
||||
_onStop();
|
||||
_onStop(_gameController.type());
|
||||
}
|
||||
}
|
||||
|
||||
void _onEnd() {
|
||||
Future<void> _startGameProcesses(FortniteVersion version, GameType type) async {
|
||||
var launcherProcess = await _createLauncherProcess(version);
|
||||
var eacProcess = await _createEacProcess(version);
|
||||
var gameProcess = await _createGameProcess(version.executable!.path, type);
|
||||
_gameController.gameInstancesMap[type] = GameInstance(gameProcess, launcherProcess, eacProcess);
|
||||
_injectOrShowError(Injectable.cranium, type);
|
||||
}
|
||||
|
||||
Future<void> _startMatchMakingServer() async {
|
||||
if(_gameController.type() != GameType.client || _settingsController.doNotAskAgain()){
|
||||
return;
|
||||
}
|
||||
|
||||
var matchmakingIp = _settingsController.matchmakingIp.text;
|
||||
if(!matchmakingIp.contains("127.0.0.1") && !matchmakingIp.contains("localhost")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var headlessServer = _gameController.gameInstancesMap[GameType.headlessServer] != null;
|
||||
var server = _gameController.gameInstancesMap[GameType.server] != null;
|
||||
if(headlessServer || server){
|
||||
return;
|
||||
}
|
||||
|
||||
var controller = CheckboxController();
|
||||
var result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
"The matchmaking ip is set to the local machine, but no server is running. "
|
||||
"If you want to start a match for your friends or just test out Reboot, you need to start a server, either now from this prompt or later manually.",
|
||||
textAlign: TextAlign.start,
|
||||
)
|
||||
),
|
||||
|
||||
const SizedBox(height: 12.0),
|
||||
|
||||
SmartCheckBox(
|
||||
controller: controller,
|
||||
content: const Text("Don't ask again")
|
||||
)
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Ignore'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Start a server'),
|
||||
)
|
||||
],
|
||||
)
|
||||
) ?? false;
|
||||
_settingsController.doNotAskAgain.value = controller.value;
|
||||
|
||||
if(!result){
|
||||
return;
|
||||
}
|
||||
|
||||
var version = _gameController.selectedVersionObs.value!;
|
||||
_startGameProcesses(
|
||||
version,
|
||||
GameType.headlessServer
|
||||
);
|
||||
}
|
||||
|
||||
Future<Process> _createGameProcess(String gamePath, GameType type) async {
|
||||
var gameProcess = await Process.start(gamePath, createRebootArgs(_gameController.username.text, type));
|
||||
gameProcess
|
||||
..exitCode.then((_) => _onEnd(type))
|
||||
..outLines.forEach((line) => _onGameOutput(line, type))
|
||||
..errLines.forEach((line) => _onGameOutput(line, type));
|
||||
return gameProcess;
|
||||
}
|
||||
|
||||
Future<void> _resetLogFile() async {
|
||||
if(_logFile != null && await _logFile!.exists()){
|
||||
await _logFile!.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Process?> _createLauncherProcess(FortniteVersion version) async {
|
||||
var launcherFile = version.launcher;
|
||||
if (launcherFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var launcherProcess = await Process.start(launcherFile.path, []);
|
||||
Win32Process(launcherProcess.pid).suspend();
|
||||
return launcherProcess;
|
||||
}
|
||||
|
||||
Future<Process?> _createEacProcess(FortniteVersion version) async {
|
||||
var eacFile = version.eacExecutable;
|
||||
if (eacFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var eacProcess = await Process.start(eacFile.path, []);
|
||||
Win32Process(eacProcess.pid).suspend();
|
||||
return eacProcess;
|
||||
}
|
||||
|
||||
void _onEnd(GameType type) {
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_closeDialogIfOpen(false);
|
||||
_onStop();
|
||||
_onStop(type);
|
||||
}
|
||||
|
||||
void _closeDialogIfOpen(bool success) {
|
||||
@@ -169,27 +269,24 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => ProgressDialog(
|
||||
text: "Launching headless server...",
|
||||
onStop: () {
|
||||
Navigator.of(context).pop(false);
|
||||
_onStop();
|
||||
}
|
||||
onStop: () =>_onEnd(_gameController.type())
|
||||
)
|
||||
);
|
||||
) ?? false;
|
||||
|
||||
if(result != null && result){
|
||||
if(result){
|
||||
return;
|
||||
}
|
||||
|
||||
_onStop();
|
||||
_onStop(_gameController.type());
|
||||
}
|
||||
|
||||
void _onGameOutput(String line) {
|
||||
void _onGameOutput(String line, GameType type) {
|
||||
if(_logFile != null){
|
||||
_logFile!.writeAsString("$line\n", mode: FileMode.append);
|
||||
}
|
||||
|
||||
if (line.contains("FOnlineSubsystemGoogleCommon::Shutdown()")) {
|
||||
_onStop();
|
||||
if (line.contains(_shutdownLine)) {
|
||||
_onStop(type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,14 +302,14 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
}
|
||||
|
||||
if(line.contains("Region ")){
|
||||
if(_gameController.type.value == GameType.client){
|
||||
_injectOrShowError(Injectable.console);
|
||||
if(type == GameType.client){
|
||||
_injectOrShowError(Injectable.console, type);
|
||||
}else {
|
||||
_injectOrShowError(Injectable.reboot)
|
||||
_injectOrShowError(Injectable.reboot, type)
|
||||
.then((value) => _closeDialogIfOpen(true));
|
||||
}
|
||||
|
||||
_injectOrShowError(Injectable.memoryFix);
|
||||
_injectOrShowError(Injectable.memoryFix, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +336,16 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onStop() {
|
||||
_gameController.started.value = false;
|
||||
_gameController.kill();
|
||||
void _onStop(GameType type) {
|
||||
_gameController.gameInstancesMap[type]?.kill();
|
||||
_gameController.gameInstancesMap.remove(type);
|
||||
if(type == _gameController.type()) {
|
||||
_gameController.started.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _injectOrShowError(Injectable injectable) async {
|
||||
var gameProcess = _gameController.gameProcess;
|
||||
Future<void> _injectOrShowError(Injectable injectable, GameType type) async {
|
||||
var gameProcess = _gameController.gameInstancesMap[type]?.gameProcess;
|
||||
if (gameProcess == null) {
|
||||
return;
|
||||
}
|
||||
@@ -255,25 +355,19 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
if(!dllPath.existsSync()) {
|
||||
await _downloadMissingDll(injectable);
|
||||
if(!dllPath.existsSync()){
|
||||
_onDllFail(dllPath);
|
||||
_onDllFail(dllPath, type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await injectDll(gameProcess.pid, dllPath.path);
|
||||
} catch (exception) {
|
||||
showSnackbar(
|
||||
appKey.currentContext!,
|
||||
Snackbar(
|
||||
content: Text("Cannot inject $injectable.dll: $exception", textAlign: TextAlign.center),
|
||||
extended: true
|
||||
)
|
||||
);
|
||||
_onStop();
|
||||
showMessage("Cannot inject $injectable.dll: $exception");
|
||||
_onStop(type);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDllFail(File dllPath) {
|
||||
void _onDllFail(File dllPath, GameType type) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if(_fail){
|
||||
return;
|
||||
@@ -282,7 +376,7 @@ class _LaunchButtonState extends State<LaunchButton> {
|
||||
_fail = true;
|
||||
_closeDialogIfOpen(false);
|
||||
showMissingDllError(path.basename(dllPath.path));
|
||||
_onStop();
|
||||
_onStop(type);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import 'package:reboot_launcher/src/dialog/add_local_version.dart';
|
||||
import 'package:reboot_launcher/src/widget/shared/smart_check_box.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../dialog/add_server_version.dart';
|
||||
import '../../util/checks.dart';
|
||||
import 'package:reboot_launcher/src/dialog/add_server_version.dart';
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import '../shared/file_selector.dart';
|
||||
|
||||
class VersionSelector extends StatefulWidget {
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/dialog/snackbar.dart';
|
||||
|
||||
import '../../util/selector.dart';
|
||||
import 'package:reboot_launcher/src/util/selector.dart';
|
||||
|
||||
class FileSelector extends StatefulWidget {
|
||||
final String label;
|
||||
|
||||
Reference in New Issue
Block a user