commit a773c490ccb36500d8f425018cd7ecfd85b5b33c Author: Alessandro Autiero Date: Sun Sep 4 23:22:03 2022 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..75ccbda --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# reboot_launcher + +Launcher for project reboot + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/assets/binaries/build.exe b/assets/binaries/build.exe new file mode 100644 index 0000000..d5870c7 Binary files /dev/null and b/assets/binaries/build.exe differ diff --git a/assets/binaries/console.dll b/assets/binaries/console.dll new file mode 100644 index 0000000..4f81c63 Binary files /dev/null and b/assets/binaries/console.dll differ diff --git a/assets/binaries/cranium.dll b/assets/binaries/cranium.dll new file mode 100644 index 0000000..b7d32ff Binary files /dev/null and b/assets/binaries/cranium.dll differ diff --git a/assets/binaries/injector.exe b/assets/binaries/injector.exe new file mode 100644 index 0000000..09aa2cc Binary files /dev/null and b/assets/binaries/injector.exe differ diff --git a/assets/binaries/port.bat b/assets/binaries/port.bat new file mode 100644 index 0000000..c77488b --- /dev/null +++ b/assets/binaries/port.bat @@ -0,0 +1 @@ +netstat -ano|find ":3551" \ No newline at end of file diff --git a/assets/binaries/reboot.dll b/assets/binaries/reboot.dll new file mode 100644 index 0000000..fcaff05 Binary files /dev/null and b/assets/binaries/reboot.dll differ diff --git a/assets/binaries/release.bat b/assets/binaries/release.bat new file mode 100644 index 0000000..6e68e6e --- /dev/null +++ b/assets/binaries/release.bat @@ -0,0 +1 @@ +for /f "tokens=5" %%a in ('netstat -aon ^| find ":3551" ^| find "LISTENING"') do taskkill /f /pid %%a \ No newline at end of file diff --git a/assets/binaries/stop.bat b/assets/binaries/stop.bat new file mode 100644 index 0000000..50f714e --- /dev/null +++ b/assets/binaries/stop.bat @@ -0,0 +1 @@ +taskkill /f /im build.exe \ No newline at end of file diff --git a/assets/icons/fortnite.ico b/assets/icons/fortnite.ico new file mode 100644 index 0000000..d5b1772 Binary files /dev/null and b/assets/icons/fortnite.ico differ diff --git a/assets/images/auties.png b/assets/images/auties.png new file mode 100644 index 0000000..b5fbf15 Binary files /dev/null and b/assets/images/auties.png differ diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..d5f63bf --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,54 @@ +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:system_theme/system_theme.dart'; +import 'package:reboot_launcher/src/page/home_page.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + SystemTheme.accentColor.load(); + doWhenWindowReady(() { + const size = Size(600, 380); + appWindow.size = size; + appWindow.alignment = Alignment.center; + appWindow.title = "Reboot Launcher"; + appWindow.show(); + }); + runApp(const RebootApplication()); +} + +class RebootApplication extends StatefulWidget { + const RebootApplication({Key? key}) : super(key: key); + + @override + State createState() => _RebootApplicationState(); +} + +class _RebootApplicationState extends State { + @override + Widget build(BuildContext context) { + final color = SystemTheme.accentColor.accent.toAccentColor(); + return FluentApp( + title: "Reboot Launcher", + themeMode: ThemeMode.system, + debugShowCheckedModeBanner: false, + color: color, + darkTheme: ThemeData( + brightness: Brightness.dark, + accentColor: color, + visualDensity: VisualDensity.standard, + focusTheme: FocusThemeData( + glowFactor: is10footScreen() ? 2.0 : 0.0, + ), + ), + theme: ThemeData( + brightness: Brightness.light, + accentColor: color, + visualDensity: VisualDensity.standard, + focusTheme: FocusThemeData( + glowFactor: is10footScreen() ? 2.0 : 0.0, + ), + ), + home: const HomePage(), + ); + } +} diff --git a/lib/src/model/fortnite_build.dart b/lib/src/model/fortnite_build.dart new file mode 100644 index 0000000..b583b46 --- /dev/null +++ b/lib/src/model/fortnite_build.dart @@ -0,0 +1,9 @@ +import 'package:version/version.dart'; + +class FortniteBuild { + final Version version; + final String link; + final bool hasManifest; + + FortniteBuild({required this.version, required this.link, required this.hasManifest}); +} diff --git a/lib/src/model/fortnite_version.dart b/lib/src/model/fortnite_version.dart new file mode 100644 index 0000000..7042037 --- /dev/null +++ b/lib/src/model/fortnite_version.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +class FortniteVersion { + String name; + Directory location; + + FortniteVersion.fromJson(json) + : name = json["name"], + location = Directory(json["location"]); + + FortniteVersion({required this.name, required this.location}); + + static File findExecutable(Directory directory, String name) { + return File( + "${directory.path}/FortniteGame/Binaries/Win64/$name"); + } + + File get executable { + return findExecutable(location, "FortniteClient-Win64-Shipping.exe"); + } + + File get launcher { + return findExecutable(location, "FortniteLauncher.exe"); + } + + File get eacExecutable { + return findExecutable(location, "FortniteClient-Win64-Shipping_EAC.exe"); + } + + Map toJson() => { + 'name': name, + 'location': location.path, + }; + + @override + String toString() { + return 'FortniteVersion{name: $name, location: $location}'; + } +} diff --git a/lib/src/page/home_page.dart b/lib/src/page/home_page.dart new file mode 100644 index 0000000..b19c73b --- /dev/null +++ b/lib/src/page/home_page.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/util/game_process_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:reboot_launcher/src/page/info_page.dart'; +import 'package:reboot_launcher/src/page/launcher_page.dart'; +import 'package:reboot_launcher/src/page/server_page.dart'; +import 'package:reboot_launcher/src/widget/window_buttons.dart'; + +import '../model/fortnite_version.dart'; +import '../util/generic_controller.dart'; +import '../util/version_controller.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + late final TextEditingController _usernameController; + late final VersionController _versionController; + late final GenericController _rebootController; + late final GenericController _localController; + late final TextEditingController _hostController; + late final TextEditingController _portController; + late final GameProcessController _gameProcessController; + late final GenericController _serverController; + late final GenericController _startedServerController; + late final GenericController _startedGameController; + + bool _loaded = false; + int _index = 0; + + Future _load() async { + if (_loaded) { + return false; + } + + var preferences = await SharedPreferences.getInstance(); + + Iterable json = jsonDecode(preferences.getString("versions") ?? "[]"); + var versions = + json.map((entry) => FortniteVersion.fromJson(entry)).toList(); + var selectedVersion = preferences.getString("version"); + _versionController = VersionController( + versions: versions, + serializer: _saveVersions, + selectedVersion: selectedVersion != null + ? versions.firstWhere((element) => element.name == selectedVersion) + : null); + + _rebootController = + GenericController(initialValue: preferences.getBool("reboot") ?? false); + + _usernameController = + TextEditingController(text: preferences.getString("${_rebootController.value ? "host" : "game"}_username")); + + _localController = + GenericController(initialValue: preferences.getBool("local") ?? true); + + _hostController = + TextEditingController(text: preferences.getString("host")); + + _portController = + TextEditingController(text: preferences.getString("port")); + + _gameProcessController = GameProcessController(); + + _serverController = GenericController(initialValue: null); + + _startedServerController = GenericController(initialValue: false); + + _startedGameController = GenericController(initialValue: false); + + _loaded = true; + + return true; + } + + Future _saveVersions() async { + var preferences = await SharedPreferences.getInstance(); + var versions = + _versionController.versions.map((entry) => entry.toJson()).toList(); + preferences.setString("versions", jsonEncode(versions)); + } + + @override + Widget build(BuildContext context) { + return NavigationView( + pane: NavigationPane( + selected: _index, + onChanged: (index) => setState(() => _index = index), + displayMode: PaneDisplayMode.top, + indicator: const EndNavigationIndicator(), + items: [ + _createPane("Launcher", FluentIcons.game), + _createPane("Server", FluentIcons.server_enviroment), + _createPane("Info", FluentIcons.info), + ], + trailing: const WindowTitleBar()), + content: FutureBuilder( + future: _load(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + "An error occurred while loading the launcher: ${snapshot.error}", + textAlign: TextAlign.center)); + } + + if (!snapshot.hasData) { + return const Center(child: ProgressRing()); + } + + return NavigationBody(index: _index, children: [ + LauncherPage( + usernameController: _usernameController, + versionController: _versionController, + rebootController: _rebootController, + serverController: _serverController, + localController: _localController, + gameProcessController: _gameProcessController, + startedGameController: _startedGameController, + startedServerController: _startedServerController + ), + ServerPage( + localController: _localController, + hostController: _hostController, + portController: _portController, + serverController: _serverController, + startedServerController: _startedServerController + ), + const InfoPage() + ]); + }), + ); + } + + PaneItem _createPane(String label, IconData icon) { + return PaneItem(icon: Icon(icon), title: Text(label)); + } +} diff --git a/lib/src/page/info_page.dart b/lib/src/page/info_page.dart new file mode 100644 index 0000000..1eae6bd --- /dev/null +++ b/lib/src/page/info_page.dart @@ -0,0 +1,44 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const String _discordLink = "https://discord.gg/rTzBQH3N"; + +class InfoPage extends StatelessWidget { + const InfoPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Expanded( + child: SizedBox() + ), + + Column( + children: [ + const CircleAvatar( + radius: 48, + backgroundImage: AssetImage("assets/images/auties.png")), + const SizedBox( + height: 16.0, + ), + const Text("Made by Auties00"), + const SizedBox( + height: 16.0, + ), + Button( + child: const Text("Join the discord"), + onPressed: () => launchUrl(Uri.parse(_discordLink))), + ], + ), + + const Expanded( + child: Align( + alignment: Alignment.bottomLeft, + child: Text("Version 1.0") + ) + ) + ], + ); + } +} diff --git a/lib/src/page/launcher_page.dart b/lib/src/page/launcher_page.dart new file mode 100644 index 0000000..f34bf76 --- /dev/null +++ b/lib/src/page/launcher_page.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/util/game_process_controller.dart'; +import 'package:reboot_launcher/src/util/generic_controller.dart'; +import 'package:reboot_launcher/src/util/version_controller.dart'; +import 'package:reboot_launcher/src/widget/deployment_selector.dart'; +import 'package:reboot_launcher/src/widget/launch_button.dart'; +import 'package:reboot_launcher/src/widget/username_box.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../widget/version_selector.dart'; + +class LauncherPage extends StatelessWidget { + final TextEditingController usernameController; + final VersionController versionController; + final GenericController rebootController; + final GenericController serverController; + final GenericController localController; + final GameProcessController gameProcessController; + final GenericController startedGameController; + final GenericController startedServerController; + final StreamController _streamController = StreamController(); + + LauncherPage( + {Key? key, + required this.usernameController, + required this.versionController, + required this.rebootController, + required this.serverController, + required this.localController, + required this.gameProcessController, + required this.startedGameController, + required this.startedServerController}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamBuilder( + stream: _streamController.stream, + builder: (context, snapshot) => UsernameBox( + controller: usernameController, + rebootController: rebootController)), + VersionSelector( + controller: versionController, + ), + DeploymentSelector( + controller: rebootController, + onSelected: () => _streamController.add(null), + enabled: false + ), + LaunchButton( + usernameController: usernameController, + versionController: versionController, + rebootController: rebootController, + serverController: serverController, + localController: localController, + gameProcessController: gameProcessController, + startedGameController: startedGameController, + startedServerController: startedServerController) + ], + ); + } +} diff --git a/lib/src/page/server_page.dart b/lib/src/page/server_page.dart new file mode 100644 index 0000000..bf47794 --- /dev/null +++ b/lib/src/page/server_page.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/util/generic_controller.dart'; +import 'package:reboot_launcher/src/widget/local_server_switch.dart'; +import 'package:reboot_launcher/src/widget/port_input.dart'; + +import '../widget/host_input.dart'; +import '../widget/server_button.dart'; + +class ServerPage extends StatefulWidget { + final GenericController localController; + final TextEditingController hostController; + final TextEditingController portController; + final GenericController serverController; + final GenericController startedServerController; + + const ServerPage( + {Key? key, + required this.localController, + required this.hostController, + required this.serverController, + required this.portController, + required this.startedServerController}) + : super(key: key); + + @override + State createState() => _ServerPageState(); +} + +class _ServerPageState extends State { + final StreamController _controller = StreamController.broadcast(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) => HostInput( + controller: widget.hostController, + localController: widget.localController)), + StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) => PortInput( + controller: widget.portController, + localController: widget.localController)), + LocalServerSwitch( + controller: widget.localController, + onSelected: (_) => _controller.add(null)), + ServerButton( + localController: widget.localController, + portController: widget.portController, + hostController: widget.hostController, + serverController: widget.serverController, + startController: widget.startedServerController) + ]); + } +} diff --git a/lib/src/util/builds_scraper.dart b/lib/src/util/builds_scraper.dart new file mode 100644 index 0000000..6de7a6c --- /dev/null +++ b/lib/src/util/builds_scraper.dart @@ -0,0 +1,75 @@ +import 'package:http/http.dart' as http; +import './../util/version.dart' as parser; +import 'package:html/parser.dart' show parse; + +import '../model/fortnite_build.dart'; + +final _cookieRegex = RegExp("(?<=document.cookie=\")(.*)(?=\";doc)"); +final _manifestSourceUrl = Uri.parse( + "https://github.com/VastBlast/FortniteManifestArchive/blob/main/README.md"); +final _archiveCookieUrl = Uri.parse("http://allinstaller.xyz/rel"); +final _archiveSourceUrl = Uri.parse("http://allinstaller.xyz/rel?i=1"); + +Future> fetchBuilds() async => + [...await _fetchArchives(), ...await _fetchManifests()]..sort((first, second) => first.version.compareTo(second.version)); + +Future> _fetchArchives() async { + var cookieResponse = await http.get(_archiveCookieUrl); + var cookie = _cookieRegex.stringMatch(cookieResponse.body); + var response = + await http.get(_archiveSourceUrl, headers: {"Cookie": cookie!}); + if (response.statusCode != 200) { + throw Exception("Erroneous status code: ${response.statusCode}"); + } + + var document = parse(response.body); + var results = []; + for (var build in document.querySelectorAll("a[href^='https']")) { + var version = parser.tryParse(build.text.replaceAll("Build ", "")); + if(version == null){ + continue; + } + + results.add(FortniteBuild( + version: version, + link: build.attributes["href"]!, + hasManifest: false + )); + } + + return results; +} + +Future> _fetchManifests() async { + var response = await http.get(_manifestSourceUrl); + if (response.statusCode != 200) { + throw Exception("Erroneous status code: ${response.statusCode}"); + } + + var document = parse(response.body); + var table = document.querySelector("table"); + if (table == null) { + throw Exception("Missing data table"); + } + + var results = []; + for (var tableEntry in table.querySelectorAll("tbody > tr")) { + var children = tableEntry.querySelectorAll("td"); + + var name = children[0].text; + var separator = name.indexOf("-") + 1; + var version = parser.tryParse(name.substring(separator, name.indexOf("-", separator))); + if(version == null){ + continue; + } + + var link = children[2].firstChild!.attributes["href"]!; + results.add(FortniteBuild( + version: version, + link: link, + hasManifest: true + )); + } + + return results; +} diff --git a/lib/src/util/download_build.dart b/lib/src/util/download_build.dart new file mode 100644 index 0000000..6a00279 --- /dev/null +++ b/lib/src/util/download_build.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:http/http.dart' as http; + +import 'package:process_run/shell.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; +import 'package:unrar_file/unrar_file.dart'; + +Future downloadManifestBuild(String manifestUrl, String destination, + Function(double) onProgress) async { + var process = await Process.start(await locateBinary("build.exe"), [manifestUrl, destination]); + + process.errLines + .where((message) => message.contains("%")) + .forEach((message) => onProgress(double.parse(message.split("%")[0]))); + + return process; +} + +Future downloadArchiveBuild(String archiveUrl, String destination, + Function(double) onProgress, Function() onRar) async { + var tempFile = File("${Platform.environment["Temp"]}/FortniteBuild${Random.secure().nextInt(1000000)}.rar"); + try{ + var client = http.Client(); + var response = await client.send(http.Request("GET", Uri.parse(archiveUrl))); + if(response.statusCode != 200){ + throw Exception("Erroneous status code: ${response.statusCode}"); + } + + print(archiveUrl); + var length = response.contentLength!; + var received = 0; + var sink = tempFile.openWrite(); + await response.stream.map((s) { + received += s.length; + onProgress((received / length) * 100); + return s; + }).pipe(sink); + onRar(); + UnrarFile.extract_rar(tempFile, destination); + }finally{ + tempFile.delete(); + } +} diff --git a/lib/src/util/game_process_controller.dart b/lib/src/util/game_process_controller.dart new file mode 100644 index 0000000..02ed167 --- /dev/null +++ b/lib/src/util/game_process_controller.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +class GameProcessController { + Process? gameProcess; + Process? launcherProcess; + Process? eacProcess; + + void kill(){ + gameProcess?.kill(ProcessSignal.sigabrt); + launcherProcess?.kill(ProcessSignal.sigabrt); + eacProcess?.kill(ProcessSignal.sigabrt); + } +} diff --git a/lib/src/util/generic_controller.dart b/lib/src/util/generic_controller.dart new file mode 100644 index 0000000..8722587 --- /dev/null +++ b/lib/src/util/generic_controller.dart @@ -0,0 +1,5 @@ +class GenericController { + T value; + + GenericController({required T initialValue}) : this.value = initialValue; +} diff --git a/lib/src/util/injector.dart b/lib/src/util/injector.dart new file mode 100644 index 0000000..8635fda --- /dev/null +++ b/lib/src/util/injector.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:process_run/shell.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; + +File injectLogFile = File("${Platform.environment["Temp"]}/server.txt"); + +// This can be done easily with win32 apis but for some reason it doesn't work on all machines +Future injectDll(int pid, String dll) async { + var shell = Shell(workingDirectory: binariesDirectory); + var process = await shell.run("./injector.exe -p $pid --inject \"$dll\""); + var success = process.outText.contains("Successfully injected module"); + if (!success) { + injectLogFile.writeAsString(process.outText, mode: FileMode.append); + } + + return success; +} diff --git a/lib/src/util/locate_binary.dart b/lib/src/util/locate_binary.dart new file mode 100644 index 0000000..b89137f --- /dev/null +++ b/lib/src/util/locate_binary.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +Future locateBinary(String binary) async{ + var originalFile = File("$binariesDirectory\\$binary"); + var tempFile = File("${Platform.environment["Temp"]}\\$binary"); + if(!(await tempFile.exists())){ + await originalFile.copy("${Platform.environment["Temp"]}\\$binary"); + } + + return tempFile.path; +} + +String get binariesDirectory => + "${File(Platform.resolvedExecutable).parent.path}\\data\\flutter_assets\\assets\\binaries"; diff --git a/lib/src/util/server.dart b/lib/src/util/server.dart new file mode 100644 index 0000000..4dbb167 --- /dev/null +++ b/lib/src/util/server.dart @@ -0,0 +1,275 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:process_run/shell.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const String _serverUrl = + "https://github.com/Lawin0129/LawinServer/archive/refs/heads/main.zip"; +const String _nodeUrl = + "https://nodejs.org/dist/v16.16.0/node-v16.16.0-x64.msi"; + +Future downloadServer(Directory output) async { + var response = await http.get(Uri.parse(_serverUrl)); + var tempZip = File("${Platform.environment["Temp"]}/lawin.zip") + ..writeAsBytesSync(response.bodyBytes); + await extractFileToDisk(tempZip.path, output.parent.path); + var result = Directory("${output.parent.path}/LawinServer-main"); + result.renameSync("${output.parent.path}/${path.basename(output.path)}"); +} + +Future downloadNode() async { + var client = HttpClient(); + client.badCertificateCallback = ((cert, host, port) => true); + var request = await client.getUrl(Uri.parse(_nodeUrl)); + var response = await request.close(); + var file = File("${Platform.environment["Temp"]}\\node.msi"); + await response.pipe(file.openWrite()); + return file; +} + +Future isPortFree() async { + var process = await Process.run(await locateBinary("port.bat"), []); + return !process.outText.contains(" LISTENING "); // Goofy way, best we got +} + +void checkAddress(BuildContext context, String host, String port) { + showDialog( + context: context, + builder: (context) => ContentDialog( + content: FutureBuilder( + future: _pingAddress(host, port), + builder: (context, snapshot) { + if(snapshot.hasData){ + return SizedBox( + height: 32, + width: double.infinity, + child: Text(snapshot.data! ? "Valid address" : "Invalid address" , textAlign: TextAlign.center) + ); + } + + return const InfoLabel( + label: "Checking address...", + child: SizedBox( + height: 32, + width: double.infinity, + child: ProgressBar() + ) + ); + } + ), + actions: [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + )) + ], + ) + ); +} + +Future _pingAddress(String host, String port) async { + var process = await Process.run( + "powershell", + ["Test-NetConnection", "-computername", host, "-port", port] + ); + + return process.exitCode == 0 + && process.outText.contains("TcpTestSucceeded : True"); +} + +Future startEmbedded(BuildContext context, bool running, bool askFreePort) async { + if (running) { + await Process.run(await locateBinary("release.bat"), []); + return null; + } + + var free = await isPortFree(); + if (!free) { + if(askFreePort) { + var shouldKill = await _showAlreadyBindPortWarning(context); + if (!shouldKill) { + return null; + } + } + + await Process.run(await locateBinary("release.bat"), []); + } + + var serverLocation = Directory("${Platform.environment["UserProfile"]}/.lawin"); + if (!(await serverLocation.exists())) { + await downloadServer(serverLocation); + } + + var serverRunner = File("${serverLocation.path}/start.bat"); + if (!(await serverRunner.exists())) { + _showNoRunnerError(context, serverRunner); + return null; + } + + var nodeProcess = await Process.run("where", ["node"]); + if(nodeProcess.exitCode != 0) { + var shouldInstall = await _showMissingNodeWarning(context); + if (!shouldInstall) { + return null; + } + + var result = await _showNodeInfo(context); + if(result == null){ + showSnackbar( + context, + const Snackbar( + content: Text( + "Node installer download cancelled" + ) + ) + ); + + return null; + } + + await launchUrl(result.uri); + showSnackbar( + context, + const Snackbar( + content: Text("Start the server when node is installed"))); // Using a infobr could be nicer + return null; + } + + var nodeModules = Directory("${serverLocation.path}/node_modules"); + if (!(await nodeModules.exists())) { + await Process.run("${serverLocation.path}/install_packages.bat", [], + workingDirectory: serverLocation.path); + } + + return await Process.start(serverRunner.path, [], + workingDirectory: serverLocation.path); +} + +Future _showNodeInfo(BuildContext context) async { + var nodeFuture = downloadNode(); + var result = await showDialog( + context: context, + builder: (context) => ContentDialog( + content: FutureBuilder( + future: nodeFuture, + builder: (context, snapshot) { + if(snapshot.hasError){ + return SizedBox( + width: double.infinity, + child: Text("An error occurred while downloading: ${snapshot.error}", + textAlign: TextAlign.center)); + } + + if(snapshot.hasData){ + return const SizedBox( + width: double.infinity, + child: Text("The download was completed successfully!", + textAlign: TextAlign.center) + ); + } + + return const InfoLabel( + label: "Downloading node installer...", + child: SizedBox( + height: 32, + width: double.infinity, + child: ProgressBar() + ) + ); + } + ), + actions: [ + FutureBuilder( + future: nodeFuture, + builder: (builder, snapshot) => SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(snapshot.hasData && !snapshot.hasError), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: Text(!snapshot.hasData && !snapshot.hasError ? 'Stop' : 'Close'), + ) + ) + ) + ], + ) + ); + + return result == null || !result ? null : await nodeFuture; +} + +void _showNoRunnerError(BuildContext context, File serverRunner) { + showDialog( + context: context, + builder: (context) => ContentDialog( + content: Text( + "Cannot start server, missing start.bat at ${serverRunner.path}", + textAlign: TextAlign.center), + actions: [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + )) + ], + )); +} + +Future _showMissingNodeWarning(BuildContext context) async { + return await showDialog( + context: context, + builder: (context) => ContentDialog( + content: const SizedBox( + height: 32, + width: double.infinity, + child: Text("Node is required to run the embedded server", + textAlign: TextAlign.center)), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(false), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + ), + FilledButton( + child: const Text('Install'), + onPressed: () => Navigator.of(context).pop(true)), + ], + )) ?? + false; +} + +Future _showAlreadyBindPortWarning(BuildContext context) async { + return await showDialog( + context: context, + builder: (context) => ContentDialog( + content: const Text( + "Port 3551 is already in use, do you want to kill the associated process?", + textAlign: TextAlign.center), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(false), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + ), + FilledButton( + child: const Text('Kill'), + onPressed: () => Navigator.of(context).pop(true)), + ], + )) ?? + false; +} diff --git a/lib/src/util/version.dart b/lib/src/util/version.dart new file mode 100644 index 0000000..3960d72 --- /dev/null +++ b/lib/src/util/version.dart @@ -0,0 +1,9 @@ +import 'package:version/version.dart'; + +Version? tryParse(String version) { + try { + return Version.parse(version); + } on FormatException catch (_) { + return null; + } +} diff --git a/lib/src/util/version_controller.dart b/lib/src/util/version_controller.dart new file mode 100644 index 0000000..5af7b28 --- /dev/null +++ b/lib/src/util/version_controller.dart @@ -0,0 +1,44 @@ +import 'package:reboot_launcher/src/model/fortnite_version.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class VersionController { + final List versions; + final Function serializer; + FortniteVersion? _selectedVersion; + + VersionController( + {required this.versions, + required this.serializer, + FortniteVersion? selectedVersion}) + : _selectedVersion = selectedVersion; + + void add(FortniteVersion version) { + versions.add(version); + serializer(); + } + + FortniteVersion removeByName(String versionName) { + var version = versions.firstWhere((element) => element.name == versionName); + remove(version); + return version; + } + + void remove(FortniteVersion version) { + versions.remove(version); + serializer(); + } + + bool get isEmpty => versions.isEmpty; + + bool get isNotEmpty => versions.isNotEmpty; + + FortniteVersion? get selectedVersion => _selectedVersion; + + set selectedVersion(FortniteVersion? selectedVersion) { + _selectedVersion = selectedVersion; + SharedPreferences.getInstance().then((preferences) => + _selectedVersion == null + ? preferences.remove("version") + : preferences.setString("version", selectedVersion!.name)); + } +} diff --git a/lib/src/widget/add_local_version.dart b/lib/src/widget/add_local_version.dart new file mode 100644 index 0000000..737a282 --- /dev/null +++ b/lib/src/widget/add_local_version.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/select_file.dart'; + +import '../model/fortnite_version.dart'; +import '../util/version_controller.dart'; + +class AddLocalVersion extends StatelessWidget { + final VersionController controller; + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _gamePathController = TextEditingController(); + + AddLocalVersion({required this.controller, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Form( + child: Builder( + builder: (formContext) => ContentDialog( + constraints: + const BoxConstraints(maxWidth: 368, maxHeight: 278), + content: _createLocalVersionDialogBody(), + actions: _createLocalVersionActions(formContext)))); + } + + List _createLocalVersionActions(BuildContext context) { + return [ + FilledButton( + onPressed: () => _closeLocalVersionDialog(context, false), + style: ButtonStyle(backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + ), + FilledButton( + child: const Text('Save'), + onPressed: () => _closeLocalVersionDialog(context, true)) + ]; + } + + Future _closeLocalVersionDialog(BuildContext context, bool save) async { + if (save) { + if (!Form.of(context)!.validate()) { + return; + } + + controller.add(FortniteVersion( + name: _nameController.text, + location: Directory(_gamePathController.text))); + } + + Navigator.of(context).pop(save); + } + + Widget _createLocalVersionDialogBody() { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormBox( + controller: _nameController, + header: "Name", + placeholder: "Type the version's name", + autofocus: true, + validator: (text) { + if (text == null || text.isEmpty) { + return 'Invalid version name'; + } + + if (controller.versions.any((element) => element.name == text)) { + return 'Existent game version'; + } + + return null; + }, + ), + SelectFile( + label: "Location", + placeholder: "Type the game folder", + windowTitle: "Select game folder", + controller: _gamePathController, + validator: _checkGameFolder) + ], + ); + } + + String? _checkGameFolder(text) { + if (text == null || text.isEmpty) { + return 'Invalid game path'; + } + + var directory = Directory(text); + if (!directory.existsSync()) { + return "Nonexistent game path"; + } + + if (!directory.existsSync()) { + return "Nonexistent game path"; + } + + if (!FortniteVersion.findExecutable(directory, "FortniteClient-Win64-Shipping.exe").existsSync()) { + return "Invalid game path"; + } + + return null; + } +} diff --git a/lib/src/widget/add_server_version.dart b/lib/src/widget/add_server_version.dart new file mode 100644 index 0000000..41c2c01 --- /dev/null +++ b/lib/src/widget/add_server_version.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/util/download_build.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; +import 'package:reboot_launcher/src/util/version_controller.dart'; +import 'package:reboot_launcher/src/widget/select_file.dart'; +import 'package:reboot_launcher/src/widget/version_name_input.dart'; + +import '../model/fortnite_build.dart'; +import '../model/fortnite_version.dart'; +import '../util/builds_scraper.dart'; +import '../util/generic_controller.dart'; +import 'build_selector.dart'; + +class AddServerVersion extends StatefulWidget { + final VersionController controller; + final Function onCancel; + + const AddServerVersion( + {required this.controller, Key? key, required this.onCancel}) + : super(key: key); + + @override + State createState() => _AddServerVersionState(); +} + +class _AddServerVersionState extends State { + static List? _builds; + late GenericController _buildController; + late TextEditingController _nameController; + late TextEditingController _pathController; + late DownloadStatus _status; + double _downloadProgress = 0; + String? _error; + Process? _process; + bool _disposed = false; + + @override + void initState() { + _buildController = GenericController(initialValue: null); + _nameController = TextEditingController(); + _pathController = TextEditingController(); + _status = DownloadStatus.none; + super.initState(); + } + + @override + void dispose() { + _disposed = true; + _pathController.dispose(); + _nameController.dispose(); + if (_process != null && _status == DownloadStatus.downloading) { + locateBinary("stop.bat") + .then((value) => Process.runSync(value, [])); // kill doesn't work :/ + widget.onCancel(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Form( + child: Builder( + builder: (context) => ContentDialog( + constraints: + const BoxConstraints(maxWidth: 368, maxHeight: 338), + content: _createDownloadVersionBody(), + actions: _createDownloadVersionOption(context)))); + } + + List _createDownloadVersionOption(BuildContext context) { + switch (_status) { + case DownloadStatus.none: + return [ + FilledButton( + onPressed: () => _onClose(), + style: ButtonStyle(backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close')), + FilledButton( + onPressed: () => _startDownload(context), + child: const Text('Download'), + ) + ]; + + case DownloadStatus.error: + return [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => _onClose(), + style: ButtonStyle(backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close') + ) + ) + ]; + default: + return [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => _onClose(), + style: + ButtonStyle(backgroundColor: ButtonState.all(Colors.red)), + child: Text( + _status == DownloadStatus.downloading ? 'Stop' : 'Close')), + ) + ]; + } + } + + void _onClose() { + Navigator.of(context).pop(true); + } + + void _startDownload(BuildContext context) async { + if (!Form.of(context)!.validate()) { + return; + } + + try { + setState(() => _status = DownloadStatus.downloading); + var build = _buildController.value!; + if(build.hasManifest) { + _process = await downloadManifestBuild(build.link, _pathController.text, + _onDownloadProgress); + _process!.exitCode.then((value) => _onDownloadComplete()); + }else{ + downloadArchiveBuild(build.link, _pathController.text, _onDownloadProgress, _onUnrar) + .then((value) => _onDownloadComplete()) + .catchError(_handleError); + } + } catch (exception) { + _handleError(exception); + } + } + + void _handleError(Object exception) { + var message = exception.toString(); + _onDownloadError(message.contains(":") + ? " ${message.substring(message.indexOf(":") + 1)}" + : message); + } + + void _onUnrar() { + setState(() => _status = DownloadStatus.extracting); + } + + void _onDownloadComplete() { + if (_disposed) { + return; + } + + setState(() { + _status = DownloadStatus.done; + widget.controller.add(FortniteVersion( + name: _nameController.text, + location: Directory(_pathController.text))); + }); + } + + void _onDownloadError(String message) { + if (_disposed) { + return; + } + + setState(() { + _status = DownloadStatus.error; + _error = message; + }); + } + + void _onDownloadProgress(double progress) { + if (_disposed) { + return; + } + + setState(() { + _status = DownloadStatus.downloading; + _downloadProgress = progress; + }); + } + + Widget _createDownloadVersionBody() { + return FutureBuilder( + future: _fetchBuilds(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text("Cannot fetch builds: ${snapshot.error}", + textAlign: TextAlign.center); + } + + if (!snapshot.hasData) { + return const InfoLabel( + label: "Fetching builds...", + child: SizedBox( + height: 32, width: double.infinity, child: ProgressBar()), + ); + } + + switch (_status) { + case DownloadStatus.none: + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BuildSelector(builds: _builds!, controller: _buildController), + VersionNameInput( + controller: _nameController, + versions: widget.controller.versions, + ), + SelectFile( + label: "Destination", + placeholder: "Type the download destination", + windowTitle: "Select download destination", + allowNavigator: false, + controller: _pathController, + validator: _checkDownloadDestination), + ], + ); + case DownloadStatus.downloading: + return InfoLabel( + label: "Downloading", + child: InfoLabel( + label: "${_downloadProgress.round()}%", + child: SizedBox( + width: double.infinity, + child: + ProgressBar(value: _downloadProgress.toDouble()))), + ); + case DownloadStatus.extracting: + return const InfoLabel( + label: "Extracting", + child: InfoLabel( + label: "This might take a while...", + child: SizedBox( + width: double.infinity, + child: + ProgressBar() + ), + ), + ); + case DownloadStatus.done: + return const SizedBox( + width: double.infinity, + child: Text("The download was completed successfully!", + textAlign: TextAlign.center)); + case DownloadStatus.error: + return SizedBox( + width: double.infinity, + child: Text( + "An exception was thrown during the download process:$_error", + textAlign: TextAlign.center)); + } + }); + } + + Future _fetchBuilds() async { + if (_builds != null) { + return false; + } + + _builds = await fetchBuilds(); + return true; + } + + String? _checkDownloadDestination(text) { + if (text == null || text.isEmpty) { + return 'Invalid download path'; + } + + if (Directory(text).existsSync()) { + return "Existent download destination"; + } + + return null; + } +} + +enum DownloadStatus { none, downloading, extracting, error, done } diff --git a/lib/src/widget/build_selector.dart b/lib/src/widget/build_selector.dart new file mode 100644 index 0000000..e0a2f86 --- /dev/null +++ b/lib/src/widget/build_selector.dart @@ -0,0 +1,46 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../model/fortnite_build.dart'; +import '../util/generic_controller.dart'; + +class BuildSelector extends StatefulWidget { + final List builds; + final GenericController controller; + + const BuildSelector( + {required this.builds, required this.controller, Key? key}) + : super(key: key); + + @override + State createState() => _BuildSelectorState(); +} + +class _BuildSelectorState extends State { + String? value; + + @override + Widget build(BuildContext context) { + widget.controller.value = widget.controller.value ?? widget.builds[0]; + return InfoLabel( + label: "Build", + child: Combobox( + placeholder: const Text('Select a fortnite build'), + isExpanded: true, + items: _createItems(), + value: widget.controller.value, + onChanged: (value) => value == null ? {} : setState(() => widget.controller.value = value) + ), + ); + } + + List> _createItems() { + return widget.builds.map((element) => _createItem(element)).toList(); + } + + ComboboxItem _createItem(FortniteBuild element) { + return ComboboxItem( + value: element, + child: Text("${element.version} ${element.hasManifest ? '[Fortnite Manifest]' : '[Google Drive]'}"), + ); + } +} diff --git a/lib/src/widget/deployment_selector.dart b/lib/src/widget/deployment_selector.dart new file mode 100644 index 0000000..85a3649 --- /dev/null +++ b/lib/src/widget/deployment_selector.dart @@ -0,0 +1,36 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/smart_switch.dart'; + +import '../util/generic_controller.dart'; + +class DeploymentSelector extends StatelessWidget { + final GenericController controller; + final VoidCallback onSelected; + final bool enabled; + + const DeploymentSelector( + {Key? key, + required this.controller, + required this.onSelected, + required this.enabled}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartSwitch( + onDisabledPress: !enabled + ? () => showSnackbar(context, + const Snackbar(content: Text("Hosting is not allowed"))) + : null, + keyName: "reboot", + label: "Host", + controller: controller, + onSelected: _onSelected, + enabled: enabled); + } + + void _onSelected(bool value) { + controller.value = value; + onSelected(); + } +} diff --git a/lib/src/widget/host_input.dart b/lib/src/widget/host_input.dart new file mode 100644 index 0000000..c53a1fd --- /dev/null +++ b/lib/src/widget/host_input.dart @@ -0,0 +1,27 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/smart_input.dart'; + +import '../util/generic_controller.dart'; + +class HostInput extends StatelessWidget { + final TextEditingController controller; + final GenericController localController; + + const HostInput( + {Key? key, required this.controller, required this.localController}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartInput( + keyName: "host", + label: "Host", + placeholder: "Type the host name", + controller: controller, + enabled: !localController.value, + onTap: () => localController.value + ? showSnackbar(context, const Snackbar(content: Text("The host is locked when embedded is on"))) + : {}, + ); + } +} diff --git a/lib/src/widget/launch_button.dart b/lib/src/widget/launch_button.dart new file mode 100644 index 0000000..bcd07a6 --- /dev/null +++ b/lib/src/widget/launch_button.dart @@ -0,0 +1,199 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:process_run/shell.dart'; +import 'package:reboot_launcher/src/util/game_process_controller.dart'; +import 'package:reboot_launcher/src/util/generic_controller.dart'; +import 'package:reboot_launcher/src/util/injector.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:win32_suspend_process/win32_suspend_process.dart'; + +import '../util/server.dart'; +import '../util/version_controller.dart'; + +class LaunchButton extends StatefulWidget { + final TextEditingController usernameController; + final VersionController versionController; + final GenericController rebootController; + final GenericController localController; + final GenericController serverController; + final GameProcessController gameProcessController; + final GenericController startedGameController; + final GenericController startedServerController; + + const LaunchButton( + {Key? key, + required this.usernameController, + required this.versionController, + required this.rebootController, + required this.serverController, + required this.localController, + required this.gameProcessController, + required this.startedGameController, + required this.startedServerController}) + : super(key: key); + + @override + State createState() => _LaunchButtonState(); +} + +class _LaunchButtonState extends State { + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.bottomCenter, + child: SizedBox( + width: double.infinity, + child: Listener( + child: Button( + onPressed: _onPressed, + child: Text(widget.startedGameController.value + ? "Close" + : "Launch")), + ), + ), + ); + } + + void _onPressed() async { + // Set state immediately for responsive reasons + if (widget.usernameController.text.isEmpty) { + showSnackbar( + context, const Snackbar(content: Text("Please type a username"))); + setState(() => widget.startedGameController.value = false); + return; + } + + if (widget.versionController.selectedVersion == null) { + showSnackbar( + context, const Snackbar(content: Text("Please select a version"))); + setState(() => widget.startedGameController.value = false); + return; + } + + if (widget.startedGameController.value) { + _onStop(); + return; + } + + if (widget.serverController.value == null && widget.localController.value && await isPortFree()) { + var process = await startEmbedded(context, false, false); + widget.serverController.value = process; + widget.startedServerController.value = process != null; + } + + _onStart(); + setState(() => widget.startedGameController.value = true); + } + + Future _onStart() async { + try{ + var version = widget.versionController.selectedVersion!; + + if(await version.launcher.exists()) { + widget.gameProcessController.launcherProcess = + await Process.start(version.launcher.path, []); + Win32Process(widget.gameProcessController.launcherProcess!.pid) + .suspend(); + } + + if(await version.eacExecutable.exists()){ + widget.gameProcessController.eacProcess = await Process.start(version.eacExecutable.path, []); + Win32Process(widget.gameProcessController.eacProcess!.pid).suspend(); + } + + widget.gameProcessController.gameProcess = await Process.start(widget.versionController.selectedVersion!.executable.path, _createProcessArguments()) + ..exitCode.then((_) => _onStop()) + ..outLines.forEach(_onGameOutput); + _injectOrShowError("cranium.dll"); + }catch(exception){ + setState(() => widget.startedGameController.value = false); + _onError(exception); + } + } + + void _onGameOutput(line) { + if (line.contains("FOnlineSubsystemGoogleCommon::Shutdown()")) { + _onStop(); + return; + } + + if (!line.contains("Game Engine Initialized")) { + return; + } + + if (!widget.rebootController.value) { + _injectOrShowError("console.dll"); + return; + } + + _injectOrShowError("reboot.dll"); + } + + Future _onError(exception) { + return showDialog( + context: context, + builder: (context) => ContentDialog( + content: SizedBox( + width: double.infinity, + child: Text("Cannot launch fortnite: $exception", + textAlign: TextAlign.center)), + actions: [ + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Close'), + )) + ], + )); + } + + void _onStop() { + setState(() => widget.startedGameController.value = false); + widget.gameProcessController.kill(); + } + + void _injectOrShowError(String binary) async { + var gameProcess = widget.gameProcessController.gameProcess; + if (gameProcess == null) { + return; + } + + try{ + var success = await injectDll(gameProcess.pid, await locateBinary(binary)); + if(success){ + return; + } + + _onInjectError(binary); + }catch(exception){ + _onInjectError(binary); + } + } + + void _onInjectError(String binary) { + showSnackbar(context, Snackbar(content: Text("Cannot inject $binary"))); + launchUrl(injectLogFile.uri); + } + + List _createProcessArguments() { + return [ + "-log", + "-epicapp=Fortnite", + "-epicenv=Prod", + "-epiclocale=en-us", + "-epicportal", + "-skippatchcheck", + "-fromfl=eac", + "-fltoken=3db3ba5dcbd2e16703f3978d", + "-caldera=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X2lkIjoiYmU5ZGE1YzJmYmVhNDQwN2IyZjQwZWJhYWQ4NTlhZDQiLCJnZW5lcmF0ZWQiOjE2Mzg3MTcyNzgsImNhbGRlcmFHdWlkIjoiMzgxMGI4NjMtMmE2NS00NDU3LTliNTgtNGRhYjNiNDgyYTg2IiwiYWNQcm92aWRlciI6IkVhc3lBbnRpQ2hlYXQiLCJub3RlcyI6IiIsImZhbGxiYWNrIjpmYWxzZX0.VAWQB67RTxhiWOxx7DBjnzDnXyyEnX7OljJm-j2d88G_WgwQ9wrE6lwMEHZHjBd1ISJdUO1UVUqkfLdU5nofBQ", + "-AUTH_LOGIN=${widget.usernameController.text}@projectreboot.dev", + "-AUTH_PASSWORD=Rebooted", + "-AUTH_TYPE=epic" + ]; + } +} diff --git a/lib/src/widget/local_server_switch.dart b/lib/src/widget/local_server_switch.dart new file mode 100644 index 0000000..65341e1 --- /dev/null +++ b/lib/src/widget/local_server_switch.dart @@ -0,0 +1,22 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/smart_switch.dart'; + +import '../util/generic_controller.dart'; + +class LocalServerSwitch extends StatelessWidget { + final GenericController controller; + final Function(bool)? onSelected; + + const LocalServerSwitch({Key? key, required this.controller, this.onSelected}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartSwitch( + keyName: "local", + label: "Embedded", + controller: controller, + onSelected: onSelected + ); + } +} diff --git a/lib/src/widget/port_input.dart b/lib/src/widget/port_input.dart new file mode 100644 index 0000000..f19fe2c --- /dev/null +++ b/lib/src/widget/port_input.dart @@ -0,0 +1,29 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/smart_input.dart'; + +import '../util/generic_controller.dart'; + +class PortInput extends StatelessWidget { + final TextEditingController controller; + final GenericController localController; + + const PortInput({ + Key? key, + required this.controller, + required this.localController + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartInput( + keyName: "port", + label: "Port", + placeholder: "Type the host port", + controller: controller, + enabled: !localController.value, + onTap: () => localController.value + ? showSnackbar(context, const Snackbar(content: Text("The port is locked when embedded is on"))) + : {}, + ); + } +} \ No newline at end of file diff --git a/lib/src/widget/select_file.dart b/lib/src/widget/select_file.dart new file mode 100644 index 0000000..0d3bec2 --- /dev/null +++ b/lib/src/widget/select_file.dart @@ -0,0 +1,52 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_desktop_folder_picker/flutter_desktop_folder_picker.dart'; + +class SelectFile extends StatefulWidget { + final String label; + final String placeholder; + final String windowTitle; + final bool allowNavigator; + final TextEditingController controller; + final String? Function(String?) validator; + + const SelectFile( + {required this.label, + required this.placeholder, + required this.windowTitle, + required this.controller, + required this.validator, + this.allowNavigator = true, + Key? key}) + : super(key: key); + + @override + State createState() => _SelectFileState(); +} + +class _SelectFileState extends State { + @override + Widget build(BuildContext context) { + return InfoLabel( + label: widget.label, + child: Row( + children: [ + Expanded( + child: TextFormBox( + controller: widget.controller, + placeholder: widget.placeholder, + validator: widget.validator)), + if (widget.allowNavigator) const SizedBox(width: 8.0), + if (widget.allowNavigator) + IconButton( + icon: const Icon(FluentIcons.open_folder_horizontal), + onPressed: _onPressed) + ], + )); + } + + void _onPressed() async { + var result = await FlutterDesktopFolderPicker.openFolderPickerDialog( + title: "Select the game folder"); + widget.controller.text = result ?? ""; + } +} diff --git a/lib/src/widget/server_button.dart b/lib/src/widget/server_button.dart new file mode 100644 index 0000000..3515ccc --- /dev/null +++ b/lib/src/widget/server_button.dart @@ -0,0 +1,66 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:process_run/shell.dart'; +import 'package:reboot_launcher/src/util/locate_binary.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../util/server.dart'; +import '../util/generic_controller.dart'; + +class ServerButton extends StatefulWidget { + final GenericController localController; + final TextEditingController hostController; + final TextEditingController portController; + final GenericController serverController; + final GenericController startController; + + const ServerButton( + {Key? key, + required this.localController, + required this.hostController, + required this.portController, + required this.serverController, required this.startController}) + : super(key: key); + + @override + State createState() => _ServerButtonState(); +} + +class _ServerButtonState extends State { + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.bottomCenter, + child: SizedBox( + width: double.infinity, + child: Button( + onPressed: _onPressed, + child: Text(widget.localController.value + ? !widget.startController.value + ? "Start" + : "Stop" + : "Check address")), + ), + ); + } + + void _onPressed() async { + if (widget.localController.value) { + var oldRunning = widget.startController.value; + setState(() => widget.startController.value = !widget.startController.value); // Needed to make the UI feel smooth + var process = await startEmbedded(context, oldRunning, true); + var updatedRunning = process != null; + if(updatedRunning != oldRunning){ + setState(() => widget.startController.value = updatedRunning); + } + + widget.serverController.value = process; + return; + } + + checkAddress(context, widget.hostController.text, widget.portController.text); + } +} diff --git a/lib/src/widget/smart_input.dart b/lib/src/widget/smart_input.dart new file mode 100644 index 0000000..5ebced7 --- /dev/null +++ b/lib/src/widget/smart_input.dart @@ -0,0 +1,75 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SmartInput extends StatefulWidget { + final String keyName; + final String label; + final String placeholder; + final TextEditingController controller; + final TextInputType type; + final bool enabled; + final VoidCallback? onTap; + final bool populate; + + const SmartInput( + {Key? key, + required this.keyName, + required this.label, + required this.placeholder, + required this.controller, + this.onTap, + this.enabled = true, + this.populate = false, + this.type = TextInputType.text}) + : super(key: key); + + @override + State createState() => _SmartInputState(); +} + +class _SmartInputState extends State { + @override + Widget build(BuildContext context) { + return widget.populate ? _buildPopulatedTextBox() : _buildTextBox(); + } + + FutureBuilder _buildPopulatedTextBox(){ + return FutureBuilder( + future: SharedPreferences.getInstance(), + builder: (context, snapshot) { + _update(snapshot.data); + return _buildTextBox(); + } + ); + } + + void _update(SharedPreferences? preferences) { + if(preferences == null){ + return; + } + + var decoded = preferences.getString(widget.keyName); + if(decoded == null) { + return; + } + + widget.controller.text = decoded; + } + + TextBox _buildTextBox() { + return TextBox( + enabled: widget.enabled, + controller: widget.controller, + header: widget.label, + keyboardType: widget.type, + placeholder: widget.placeholder, + onChanged: _save, + onTap: widget.onTap, + ); + } + + Future _save(String value) async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString(widget.keyName, value); + } +} diff --git a/lib/src/widget/smart_selector.dart b/lib/src/widget/smart_selector.dart new file mode 100644 index 0000000..6f992ee --- /dev/null +++ b/lib/src/widget/smart_selector.dart @@ -0,0 +1,111 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SmartSelector extends StatefulWidget { + final String keyName; + final String? label; + final String placeholder; + final List options; + final SmartSelectorItem Function(String)? itemBuilder; + final Function(String)? onSelected; + final bool serializer; + final String? initialValue; + final bool enabled; + final bool useFirstItemByDefault; + + const SmartSelector({Key? key, + required this.keyName, + required this.placeholder, + required this.options, + required this.initialValue, + this.itemBuilder, + this.onSelected, + this.label, + this.serializer = true, + this.enabled = true, + this.useFirstItemByDefault = true}) + : super(key: key); + + @override + State createState() => _SmartSelectorState(); +} + +class _SmartSelectorState extends State { + String? _selected; + + @override + void initState() { + _selected = widget.initialValue; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return widget.label == null ? _buildBody() : _buildLabel(); + } + + InfoLabel _buildLabel() { + return InfoLabel(label: widget.label!, child: _buildBody()); + } + + SizedBox _buildBody() { + return SizedBox( + width: double.infinity, + child: DropDownButton( + leading: Text(_selected ?? widget.placeholder), + items: widget.options.map(_createOption).toList() + ), + ); + } + + MenuFlyoutItem _createOption(String option) { + var function = widget.itemBuilder ?? _createDefaultItem; + var item = function(option); + return MenuFlyoutItem( + key: item.key, + text: item.text, + onPressed: () => widget.enabled && item.clickable ? _onSelected(option) : {}, + leading: item.leading, + trailing: item.trailing, + selected: item.selected + ); + } + + SmartSelectorItem _createDefaultItem(String name) { + return SmartSelectorItem( + text: SizedBox(width: double.infinity, child: Text(name))); + } + + void _onSelected(String name) { + setState(() { + widget.onSelected?.call(name); + _selected = name; + if(!widget.serializer){ + return; + } + + _serialize(name); + }); + } + + Future _serialize(String value) async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString(widget.keyName, value); + } +} + +class SmartSelectorItem { + final Key? key; + final Widget? leading; + final Widget text; + final Widget? trailing; + final bool selected; + final bool clickable; + + SmartSelectorItem({this.key, + this.leading, + required this.text, + this.trailing, + this.selected = false, + this.clickable = true}); +} diff --git a/lib/src/widget/smart_switch.dart b/lib/src/widget/smart_switch.dart new file mode 100644 index 0000000..ff21c3d --- /dev/null +++ b/lib/src/widget/smart_switch.dart @@ -0,0 +1,75 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:system_theme/system_theme.dart'; + +import '../util/generic_controller.dart'; + +class SmartSwitch extends StatefulWidget { + final String keyName; + final String label; + final bool enabled; + final Function(bool)? onSelected; + final Function()? onDisabledPress; + final GenericController controller; + + const SmartSwitch( + {Key? key, + required this.keyName, + required this.label, + required this.controller, + this.onSelected, + this.enabled = true, + this.onDisabledPress}) + : super(key: key); + + @override + State createState() => _SmartSwitchState(); +} + +class _SmartSwitchState extends State { + Future _save(bool state) async { + final preferences = await SharedPreferences.getInstance(); + preferences.setBool(widget.keyName, state); + } + + @override + Widget build(BuildContext context) { + return InfoLabel( + label: widget.label, + child: ToggleSwitch( + enabled: widget.enabled, + onDisabledPress: widget.onDisabledPress, + checked: widget.controller.value, + onChanged: _onChanged, + style: ToggleSwitchThemeData.standard(ThemeData( + checkedColor: _toolTipColor.withOpacity(_checkedOpacity), + uncheckedColor: _toolTipColor.withOpacity(_uncheckedOpacity), + borderInputColor: _toolTipColor.withOpacity(_uncheckedOpacity), + accentColor: _bodyColor + .withOpacity(widget.controller.value + ? _checkedOpacity + : _uncheckedOpacity) + .toAccentColor())))); + } + + Color get _toolTipColor => + FluentTheme.of(context).brightness.isDark ? Colors.white : Colors.black; + + Color get _bodyColor => SystemTheme.accentColor.accent; + + double get _checkedOpacity => widget.enabled ? 1 : 0.5; + + double get _uncheckedOpacity => widget.enabled ? 0.8 : 0.5; + + void _onChanged(checked) { + if (!widget.enabled) { + return; + } + + setState(() { + widget.controller.value = checked; + widget.onSelected?.call(widget.controller.value); + _save(checked); + }); + } +} diff --git a/lib/src/widget/username_box.dart b/lib/src/widget/username_box.dart new file mode 100644 index 0000000..00ec451 --- /dev/null +++ b/lib/src/widget/username_box.dart @@ -0,0 +1,22 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:reboot_launcher/src/widget/smart_input.dart'; + +import '../util/generic_controller.dart'; + +class UsernameBox extends StatelessWidget { + final TextEditingController controller; + final GenericController rebootController; + + const UsernameBox({Key? key, required this.controller, required this.rebootController}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartInput( + keyName: "${rebootController.value ? 'host' : 'game'}_username", + label: "Username", + placeholder: "Type your ${rebootController.value ? 'hosting' : "in-game"} username", + controller: controller, + populate: true + ); + } +} diff --git a/lib/src/widget/version_name_input.dart b/lib/src/widget/version_name_input.dart new file mode 100644 index 0000000..b95d11d --- /dev/null +++ b/lib/src/widget/version_name_input.dart @@ -0,0 +1,32 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../model/fortnite_version.dart'; + +class VersionNameInput extends StatelessWidget { + final TextEditingController controller; + final List versions; + const VersionNameInput({required this.controller, required this.versions, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TextFormBox( + controller: controller, + header: "Name", + placeholder: "Type the version's name", + autofocus: true, + validator: _validate, + ); + } + + String? _validate(String? text){ + if (text == null || text.isEmpty) { + return 'Invalid version name'; + } + + if (versions.any((element) => element.name == text)) { + return 'Existent game version'; + } + + return null; + } +} diff --git a/lib/src/widget/version_selector.dart b/lib/src/widget/version_selector.dart new file mode 100644 index 0000000..05c7892 --- /dev/null +++ b/lib/src/widget/version_selector.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' + show showMenu, PopupMenuEntry, PopupMenuItem; +import 'package:reboot_launcher/src/util/version_controller.dart'; +import 'package:reboot_launcher/src/widget/add_local_version.dart'; +import 'package:reboot_launcher/src/widget/add_server_version.dart'; +import 'package:reboot_launcher/src/widget/smart_selector.dart'; + +import '../model/fortnite_version.dart'; + +class VersionSelector extends StatefulWidget { + final VersionController controller; + + const VersionSelector({Key? key, required this.controller}) : super(key: key); + + @override + State createState() => _VersionSelectorState(); +} + +class _VersionSelectorState extends State { + final StreamController _streamController = StreamController(); + + @override + Widget build(BuildContext context) { + return InfoLabel( + label: "Version", + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + Expanded( + child: StreamBuilder( + stream: _streamController.stream, + builder: (context, snapshot) => SmartSelector( + keyName: "version", + placeholder: "Select a version", + options: widget.controller.isEmpty ? ["No versions available"] : widget.controller.versions + .map((element) => element.name) + .toList(), + useFirstItemByDefault: false, + itemBuilder: (name) => _createVersionItem(name, widget.controller.versions.isNotEmpty), + onSelected: _onSelected, + serializer: false, + initialValue: widget.controller.selectedVersion?.name, + enabled: widget.controller.versions.isNotEmpty + ) + ) + ), + const SizedBox( + width: 16, + ), + Tooltip( + message: "Add a local fortnite build to the versions list", + child: Button( + child: const Icon(FluentIcons.open_file), + onPressed: () => _openLocalVersionDialog(context) + ), + ), + const SizedBox( + width: 16, + ), + Tooltip( + message: "Download a fortnite build from the archive", + child: Button( + child: const Icon(FluentIcons.download), + onPressed: () => _openDownloadVersionDialog(context)), + ) + ], + ))); + } + + void _onSelected(String selected) { + widget.controller.selectedVersion = widget.controller.versions + .firstWhere((element) => selected == element.name); + } + + SmartSelectorItem _createVersionItem(String name, bool enabled) { + return SmartSelectorItem( + text: _withListener(name, enabled, SizedBox(width: double.infinity, child: Text(name))), + trailing: const Expanded(child: SizedBox())); + } + + Listener _withListener(String name, bool enabled, Widget child) { + return Listener( + onPointerDown: (event) { + if (event.kind != PointerDeviceKind.mouse || + event.buttons != kSecondaryMouseButton + || !enabled) { + return; + } + + _openMenu(context, name, event.position); + }, + child: child + ); + } + + void _openDownloadVersionDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (dialogContext) => AddServerVersion( + controller: widget.controller, + onCancel: () => WidgetsBinding.instance + .addPostFrameCallback((_) => showSnackbar( + context, + const Snackbar(content: Text("Download cancelled")) + )) + ) + ); + + _streamController.add(true); + } + + void _openLocalVersionDialog(BuildContext context) async { + var result = await showDialog( + context: context, + builder: (context) => AddLocalVersion(controller: widget.controller)); + + if(result == null || !result){ + return; + } + + _streamController.add(false); + } + + void _openMenu( + BuildContext context, String name, Offset offset) { + showMenu( + context: context, + items: [ + const PopupMenuItem(value: 0, child: Text("Open in explorer")), + const PopupMenuItem(value: 1, child: Text("Delete")) + ], + position: RelativeRect.fromLTRB(offset.dx, offset.dy, offset.dx, offset.dy), + ).then((value) { + if(value == 0){ + Navigator.of(context).pop(); + Process.run( + "explorer.exe", + [widget.controller.versions.firstWhere((element) => element.name == name).location.path] + ); + return; + } + + if(value != 1) { + return; + } + + Navigator.of(context).pop(); + var version = widget.controller.removeByName(name); + _openDeleteDialog(context, version); + _streamController.add(false); + if (widget.controller.selectedVersion?.name != name && + widget.controller.isNotEmpty) { + return; + } + + widget.controller.selectedVersion = null; + _streamController.add(false); + }); + } + + void _openDeleteDialog(BuildContext context, FortniteVersion version) { + showDialog( + context: context, + builder: (context) => ContentDialog( + content: const SizedBox( + height: 32, + width: double.infinity, + child: Text("Delete associated game path?", + textAlign: TextAlign.center)), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.green)), + child: const Text('Keep'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + version.location.delete(); + }, + style: + ButtonStyle(backgroundColor: ButtonState.all(Colors.red)), + child: const Text('Delete'), + ) + ], + )); + } +} diff --git a/lib/src/widget/window_buttons.dart b/lib/src/widget/window_buttons.dart new file mode 100644 index 0000000..4d0f225 --- /dev/null +++ b/lib/src/widget/window_buttons.dart @@ -0,0 +1,53 @@ +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:system_theme/system_theme.dart'; + +class WindowTitleBar extends StatelessWidget { + const WindowTitleBar({Key? key}) : 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: lightMode ? Colors.black : Colors.white, + iconMouseDown: lightMode ? Colors.black : Colors.white, + iconMouseOver: lightMode ? Colors.black : Colors.white, + normal: Colors.transparent, + mouseOver: _getColor(context), + mouseDown: _getColor(context).withOpacity(0.7)), + ), + MaximizeWindowButton( + colors: WindowButtonColors( + iconNormal: lightMode ? Colors.black : Colors.white, + iconMouseDown: lightMode ? Colors.black : Colors.white, + iconMouseOver: lightMode ? Colors.black : Colors.white, + normal: Colors.transparent, + mouseOver: _getColor(context), + mouseDown: _getColor(context).withOpacity(0.7)), + ), + CloseWindowButton( + onPressed: () { + appWindow.close(); + }, + colors: WindowButtonColors( + iconNormal: lightMode ? Colors.black : Colors.white, + 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 _getColor(BuildContext context) => + FluentTheme.of(context).brightness.isDark + ? SystemTheme.accentColor.light + : SystemTheme.accentColor.light; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5a23db6 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,57 @@ +name: reboot_launcher +description: Launcher for project reboot + +publish_to: 'none' + +environment: + sdk: ">=2.17.6 <3.0.5" + +dependencies: + flutter: + sdk: flutter + + bitsdojo_window: ^0.1.2 + fluent_ui: ^3.12.0 + system_theme: ^2.0.0 + http: ^0.13.5 + html: ^0.15.0 + skeleton_loader: ^2.0.0+4 + shared_preferences: ^2.0.15 + flutter_desktop_folder_picker: ^0.0.1 + context_menus: ^1.0.1 + process_run: ^0.12.3+2 + url_launcher: ^6.1.5 + archive: ^3.3.1 + win32_suspend_process: ^1.0.0 + version: ^3.0.2 + unrar_file: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^2.0.1 + msix: ^3.6.3 + +flutter: + uses-material-design: true + assets: + - assets/icons/ + - assets/binaries/ + - assets/images/ + +msix_config: + display_name: Reboot Launcher + app_installer: + publish_folder_path: ./dist + hours_between_update_checks: 0 + automatic_background_task: false + update_blocks_activation: true + show_prompt: true + force_update_from_any_version: false + publisher_display_name: Reboot + publisher: it.auties.reboot + msix_version: 2.0.0.0 + logo_path: ./assets/icons/fortnite.ico + architecture: x64 + capabilities: "internetClient" diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..d046767 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(reboot_launcher LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "reboot_launcher") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..930d207 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..40a3f2d --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + BitsdojoWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); + FlutterDesktopFolderPickerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterDesktopFolderPickerPlugin")); + SystemThemePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemThemePlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..6814de3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + bitsdojo_window_windows + flutter_desktop_folder_picker + system_theme + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..b9e550f --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..eddb8d2 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "reboot_launcher" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "reboot_launcher" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "reboot_launcher.exe" "\0" + VALUE "ProductName", "reboot_launcher" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..b43b909 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..6b7deb6 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,46 @@ +#include +auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP); + +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"reboot_launcher", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..d5b1772 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..f5bf9fa --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..c10f08d --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..17ba431 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_