diff --git a/.gitignore b/.gitignore index 2734389..d243bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.tar +*.tar Ubuntu* # Miscellaneous diff --git a/README.md b/README.md index eddd615..379d241 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ A quick way to manage your WSL2 instances with a GUI. ![image](https://user-images.githubusercontent.com/7342321/133839228-42ae7d5f-e0d6-45c6-9d41-fc787ad714fb.png) +## Build + +Enable Flutter Desktop `flutter config --enable-windows-desktop` + +https://flutter.dev/desktop + ## Why WSL2 is great. It makes it very simple to spin up new workplaces with different systems for the project you need or just testing. @@ -25,9 +31,10 @@ Fairly simple. Download the latest release from the releases Page and start wsl2 - [x] Copy WSL - [x] Delete WSL - [x] Start WSL -- [ ] Rename -- [ ] Create -- [ ] Download +- [X] Rename WSL +- [X] Create WSL +- [X] Download WSL +- [X] Select rootfs from storage ## FAQ diff --git a/lib/api.dart b/lib/api.dart index fffce12..f1c3fa8 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -9,10 +9,13 @@ class WSLApi { } // Start a WSL distro by name - Future copy(String distribution) async { + Future copy(String distribution, String newName, {String location = ''}) async { + if (location == '') { + location = distribution + '.tar'; + } String exportRes = await export(distribution, distribution + '.tar'); - String importRes = await import(distribution + '-copy', - './' + distribution + '-copy', distribution + '.tar'); + String importRes = await import(newName, + './' + newName, distribution + '.tar'); return exportRes + ' ' + importRes; } @@ -30,6 +33,13 @@ class WSLApi { return results.stdout; } + // Install a WSL distro by name + Future install(String distribution) async { + ProcessResult results = + await Process.run('wsl', ['--install', '-d', distribution]); + return results.stdout; + } + // Import a WSL distro by name Future import( String distribution, String installLocation, String location) async { @@ -53,6 +63,26 @@ class WSLApi { return list; } + // Returns list of downloadable WSL distros + Future> getDownloadable() async { + ProcessResult results = + await Process.run('wsl', ['--list', '--online'], stdoutEncoding: null); + String output = utf8Convert(results.stdout); + List list = []; + bool nameStarted = false; + output.split('\n').forEach((line) { + // Filter out docker data + if (line != '' && nameStarted) { + list.add(line.split(' ')[0]); + } + // List started + if (line.startsWith('NAME')) { + nameStarted = true; + } + }); + return list; + } + // Convert bytes to human readable string while removing non-ascii characters String utf8Convert(List bytes) { List utf8Lines = List.from(bytes); diff --git a/lib/distro_create_component.dart b/lib/distro_create_component.dart new file mode 100644 index 0000000..4f43621 --- /dev/null +++ b/lib/distro_create_component.dart @@ -0,0 +1,124 @@ +import 'api.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:file_picker/file_picker.dart'; + +Widget createComponent(WSLApi api, statusMsg(msg)) { + final autoSuggestBox = TextEditingController(); + final locationController = TextEditingController(); + final nameController = TextEditingController(); + final items = ['Debian', 'Ubuntu']; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: TextBox( + controller: nameController, + placeholder: 'Name', + suffix: IconButton( + icon: const Icon(FluentIcons.close, size: 15.0), + onPressed: () {}, + ), + ), + ), + Expanded( + child: FutureBuilder < List < String >> ( + future: api.getDownloadable(), + builder: (context, snapshot) { + if (snapshot.hasData) { + List < String > list = snapshot.data ?? []; + return AutoSuggestBox < String > ( + controller: autoSuggestBox, + items: list, + onSelected: (text) { + print(text); + }, + textBoxBuilder: (context, controller, focusNode, key) { + return TextBox( + key: key, + controller: controller, + focusNode: focusNode, + suffix: Row( + children: [IconButton( + icon: const Icon(FluentIcons.close, size: 15.0), + onPressed: () { + controller.clear(); + focusNode.unfocus(); + }, + ), + IconButton( + icon: const Icon(FluentIcons.open_folder_horizontal, + size: 15.0), + onPressed: () async { + FilePickerResult ? result = + await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['*'], + ); + + if (result != null) { + controller.text = result.files.single.path!; + } else { + // User canceled the picker + } + }, + ), + ] + ), + placeholder: 'Distro', + ); + }, + ); + } else if (snapshot.hasError) { + return Text('${snapshot.error}'); + } + // By default, show a loading spinner. + return const Center(child: ProgressRing()); + } + )), + Expanded( + child: TextBox( + controller: locationController, + placeholder: 'Save location', + suffix: IconButton( + icon: const Icon(FluentIcons.open_folder_horizontal, + size: 15.0), + onPressed: () async { + String ? path = + await FilePicker.platform.getDirectoryPath(); + if (path != null) { + locationController.text = path; + } else { + // User canceled the picker + } + }, + ), + ), + ), + Button( + onPressed: () async { + List downloadable = await api.getDownloadable(); + if (downloadable.contains(autoSuggestBox.text)) { + // Get distro from internet + // Install distro + statusMsg('Downloading ${autoSuggestBox.text}. This might take a while...'); + await api.install(autoSuggestBox.text); + // Copy installed to name + statusMsg('Creating ${nameController.text}. This might take a while...'); + await api.copy(autoSuggestBox.text, nameController.text, location: locationController.text); + statusMsg('DONE: Created ${nameController.text}.'); + } else { + // Get distro from local storage + // Copy local storage to name + statusMsg('Creating ${nameController.text}. This might take a while...'); + await api.import(nameController.text, locationController.text, autoSuggestBox.text,); + statusMsg('DONE: Created ${nameController.text}.'); + } + }, + child: const Padding( + padding: EdgeInsets.all(6.0), + child: Text('Create'), + ), + ), + ], + ); +} \ No newline at end of file diff --git a/lib/distro_list_component.dart b/lib/distro_list_component.dart index 4259236..988365a 100644 --- a/lib/distro_list_component.dart +++ b/lib/distro_list_component.dart @@ -10,7 +10,7 @@ FutureBuilder> distroList(WSLApi api, statusMsg(msg)) { List list = snapshot.data ?? []; for (String item in list) { newList.add(Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.only(top:8.0), child: Container( color: const Color.fromRGBO(0, 0, 0, 0.1), child: ListTile( @@ -28,16 +28,14 @@ FutureBuilder> distroList(WSLApi api, statusMsg(msg)) { icon: const Icon(FluentIcons.copy), onPressed: () async { print('pushed copy ' + item); - statusMsg( - 'Copying ' + item + '. This might take a while...'); - String result = await api.copy(item); - statusMsg('DONE: Copying ' + item + '.'); + copyDialog(context, item, api, statusMsg); }, ), IconButton( icon: const Icon(FluentIcons.rename), onPressed: () { print('pushed rename ' + item); + renameDialog(context, item, api, statusMsg); }, ), IconButton( @@ -62,7 +60,7 @@ FutureBuilder> distroList(WSLApi api, statusMsg(msg)) { } // By default, show a loading spinner. - return const ProgressRing(); + return const Center(child: ProgressRing()); }, ); } @@ -72,7 +70,7 @@ deleteDialog(context, item, api, statusMsg(msg)) { context: context, builder: (context) { return ContentDialog( - title: Text('Delete ' + item + ' permanently?'), + title: Text('Delete $item permanently?'), content: const Text( 'If you delete this Distro you won\'t be able to recover it. Do you want to delete it?'), actions: [ @@ -85,7 +83,7 @@ deleteDialog(context, item, api, statusMsg(msg)) { onPressed: () { Navigator.pop(context); api.remove(item); - statusMsg('DONE: Deleted ' + item + '.'); + statusMsg('DONE: Deleted $item.'); }), Button( child: const Text('Cancel'), @@ -97,3 +95,84 @@ deleteDialog(context, item, api, statusMsg(msg)) { }, ); } + +final renameController = TextEditingController(); +renameDialog(context, item, api, statusMsg(msg)) { + showDialog( + context: context, + builder: (context) { + return ContentDialog( + title: Text('Rename $item'), + content: Column( + children: [ + const Text('Warning: Renaming will recreate the whole WSL2 instance.'), + TextBox( + controller: renameController, + placeholder: item, + ), + ], + ), + actions: [ + Button( + child: const Text('Rename'), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.blue), + foregroundColor: ButtonState.all(Colors.white), + ), + onPressed: () async { + Navigator.pop(context); + statusMsg('Renaming $item to ${renameController.text}. This might take a while...'); + await api.copy(item, renameController.text); + await api.remove(item); + statusMsg('DONE: Renamed $item to ${renameController.text}.'); + }), + Button( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }), + ], + ); + }, + ); +} + +final copyController = TextEditingController(); +copyDialog(context, item, api, statusMsg(msg)) { + showDialog( + context: context, + builder: (context) { + return ContentDialog( + title: Text('Copy $item'), + content: Column( + children: [ + Text('Copy the WSL instance $item.'), + TextBox( + controller: copyController, + placeholder: item, + ), + ], + ), + actions: [ + Button( + child: const Text('Copy'), + style: ButtonStyle( + backgroundColor: ButtonState.all(Colors.blue), + foregroundColor: ButtonState.all(Colors.white), + ), + onPressed: () async { + Navigator.pop(context); + statusMsg('Copying $item. This might take a while...'); + await api.copy(item, copyController.text); + statusMsg('DONE: Copied $item to ${copyController.text}.'); + }), + Button( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }), + ], + ); + }, + ); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6bf90e1..bfee280 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,11 @@ import 'package:desktop_window/desktop_window.dart'; import 'package:fluent_ui/fluent_ui.dart'; -//import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:system_theme/system_theme.dart'; -import 'package:file_picker/file_picker.dart'; import 'api.dart'; import 'distro_list_component.dart'; +import 'distro_create_component.dart'; void main() { runApp(const MyApp()); @@ -20,12 +20,12 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return FluentApp( - title: 'Flutter Demo', + title: 'WSL2 Distro Manager by Bostrot', theme: ThemeData( accentColor: SystemTheme.accentInstance.accent.toAccentColor(), brightness: Brightness.light, // or Brightness.dark ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + home: const MyHomePage(title: 'WSL2 Distro Manager by Bostrot'), ); } } @@ -40,13 +40,9 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - final autoSuggestBox = TextEditingController(); - final locationController = TextEditingController(); - final items = ['Debian', 'Ubuntu']; String status = ''; - String? _extension; - WSLApi api = new WSLApi(); + WSLApi api = WSLApi(); void statusMsg(msg) { setState(() { @@ -63,91 +59,24 @@ class _MyHomePageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.only(left: 12.0), - child: Text( - 'New WSL2 instance:', - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - SizedBox( - width: 150, - child: TextBox( - placeholder: 'Name', - suffix: IconButton( - icon: const Icon(FluentIcons.close, size: 15.0), - onPressed: () {}, - ), - ), - ), - SizedBox( - width: 150, - child: AutoSuggestBox( - controller: autoSuggestBox, - items: items, - onSelected: (text) { - print(text); - }, - textBoxBuilder: (context, controller, focusNode, key) { - return TextBox( - key: key, - controller: controller, - focusNode: focusNode, - suffix: IconButton( - icon: const Icon(FluentIcons.close, size: 15.0), - onPressed: () { - controller.clear(); - focusNode.unfocus(); - }, - ), - placeholder: 'Distro', - ); - }, - )), - SizedBox( - width: 150, - child: TextBox( - controller: locationController, - placeholder: 'Save location', - suffix: IconButton( - icon: const SizedBox( - child: Icon(FluentIcons.open_folder_horizontal, - size: 15.0), - ), - onPressed: () async { - FilePickerResult? result = - await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['*'], - ); - - if (result != null) { - locationController.text = result.files.single.path!; - } else { - // User canceled the picker - } - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Button( - onPressed: () {}, - child: const Padding( - padding: EdgeInsets.all(6.0), - child: Text('Create'), - ), - )), - ], - ), - Center( + createComponent(api, statusMsg), + Padding( + padding: const EdgeInsets.only(left: 30.0, right: 30.0, top: 14.0, bottom: 8.0), child: Builder( builder: (ctx) { if (status != '') { - return Text(status); + return Container( + color: const Color.fromRGBO(0, 0, 0, 0.05), + child: ListTile( + title: Text(status), + leading: const Icon(FluentIcons.info), + trailing: IconButton(icon: const Icon(FluentIcons.close), onPressed: () { + setState(() { + status = ''; + }); + }), + ) + ); } else { return const Text(''); } @@ -155,6 +84,25 @@ class _MyHomePageState extends State { ), ), distroList(api, statusMsg), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton(onPressed: () async { + await canLaunch('https://bostrot.com') ? + await launch('https://bostrot.com') : throw 'Could not launch URL'; + }, child: const Text("Created by Bostrot", style: TextStyle(fontSize: 12.0))), + const Text('|', style: TextStyle(fontSize: 12.0)), + TextButton(onPressed: () async { + await canLaunch('https://github.com/bostrot/wsl2-distro-manager') ? + await launch('https://github.com/bostrot/wsl2-distro-manager') : throw 'Could not launch URL'; + }, child: const Text("Visit GitHub", style: TextStyle(fontSize: 12.0))), + const Text('|', style: TextStyle(fontSize: 12.0)), + TextButton(onPressed: () async { + await canLaunch('http://paypal.me/bostrot') ? + await launch('http://paypal.me/bostrot') : throw 'Could not launch URL'; + }, child: const Text("Donate", style: TextStyle(fontSize: 12.0))), + ], + ) ], ), ), diff --git a/pubspec.lock b/pubspec.lock index b1cbe40..51dcc3b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,7 +7,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.8.2" + version: "2.8.1" boolean_selector: dependency: transitive description: @@ -143,7 +143,7 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.11" + version: "0.12.10" meta: dependency: transitive description: @@ -232,7 +232,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.3" + version: "0.4.2" typed_data: dependency: transitive description: @@ -240,6 +240,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.10" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1eca40e..ff52386 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 @@ -39,6 +38,7 @@ dependencies: git: https://github.com/bdlukaa/fluent_ui.git system_theme: ^1.0.1 file_picker: ^4.0.3 + url_launcher: ^6.0.10 dev_dependencies: flutter_test: @@ -56,7 +56,6 @@ dev_dependencies: # The following section is specific to Flutter. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2a4cb0f..ae91d32 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { DesktopWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopWindowPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cee24f5..4eb8f27 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_window system_theme + url_launcher_windows ) set(PLUGIN_BUNDLED_LIBRARIES)